diff --git a/.build-obj/Debug/net8.0/Fitness_core_BE.GlobalUsings.g.cs b/.build-obj/Debug/net8.0/Fitness_core_BE.GlobalUsings.g.cs new file mode 100644 index 0000000..5e6145d --- /dev/null +++ b/.build-obj/Debug/net8.0/Fitness_core_BE.GlobalUsings.g.cs @@ -0,0 +1,17 @@ +// +global using Microsoft.AspNetCore.Builder; +global using Microsoft.AspNetCore.Hosting; +global using Microsoft.AspNetCore.Http; +global using Microsoft.AspNetCore.Routing; +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Hosting; +global using Microsoft.Extensions.Logging; +global using System; +global using System.Collections.Generic; +global using System.IO; +global using System.Linq; +global using System.Net.Http; +global using System.Net.Http.Json; +global using System.Threading; +global using System.Threading.Tasks; diff --git a/.build-obj/Debug/net8.0/Fitness_core_BE.csproj.FileListAbsolute.txt b/.build-obj/Debug/net8.0/Fitness_core_BE.csproj.FileListAbsolute.txt new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0d4dc3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Build artifacts +bin/ +obj/ + +# Visual Studio +.vs/ +*.user +*.suo +*.userosscache +*.sln.docstates + +# Logs +*.log + +# zips +*.zip + +publish/ + +# Entity Framework migrations are normally kept local in this repository. +# Track the unified access migration and snapshot because the application +# requires them to upgrade existing client installations safely. +Migrations/* +!Migrations/20260727103618_UnifiedAccessProfiles.cs +!Migrations/20260727103618_UnifiedAccessProfiles.Designer.cs +!Migrations/20260727104258_AttendanceDeviceScope.cs +!Migrations/20260727104258_AttendanceDeviceScope.Designer.cs +!Migrations/20260727115125_AttendanceSourceEventIdempotency.cs +!Migrations/20260727115125_AttendanceSourceEventIdempotency.Designer.cs +!Migrations/20260727120451_DeviceReportedPresence.cs +!Migrations/20260727120451_DeviceReportedPresence.Designer.cs +!Migrations/20260728064848_GatewayOnboardingSelection.cs +!Migrations/20260728064848_GatewayOnboardingSelection.Designer.cs +!Migrations/AppDBContextModelSnapshot.cs +!Migrations/20260729081539_PersistGymCoordinates.cs +!Migrations/20260729081539_PersistGymCoordinates.Designer.cs diff --git a/.postman/config.json b/.postman/config.json new file mode 100644 index 0000000..9ff04f4 --- /dev/null +++ b/.postman/config.json @@ -0,0 +1,17 @@ +{ + "workspace": { + "id": "0d87f5dc-8434-403b-88bc-6d05f4639c4c" + }, + "entities": { + "environments": [], + "flows": [], + "globals": [], + "mocks": [], + "specs": [ + "..\\obj\\Debug\\net8.0\\EndpointInfo\\Fitness-core-BE.json" + ], + "collections": [ + "..\\Fitness_Core_API.postman_collection.json" + ] + } +} \ No newline at end of file diff --git a/AI/GeminiClient.cs b/AI/GeminiClient.cs new file mode 100644 index 0000000..fb90c25 --- /dev/null +++ b/AI/GeminiClient.cs @@ -0,0 +1,68 @@ +using System.Text.Json; + +namespace Fitness_core_BE.AI +{ + public class GeminiClient : IAiClient + { + private readonly HttpClient _http; + private readonly IConfiguration _config; + + public GeminiClient(HttpClient http, IConfiguration configuration) + { + _http = http; + _config = configuration; + } + + public async Task GenerateJsonAsync( string systemPrompt, object payload) + { + string apiKey = _config["Gemini:ApiKey"]; + + if (string.IsNullOrEmpty(apiKey)) + throw new Exception("Gemini API key is not configured."); + + var requestBody = new + { + contents = new[] + { + new + { + parts = new[] + { + new { text = systemPrompt }, + new { text = JsonSerializer.Serialize(payload) } + } + } + }, + generationConfig = new + { + temperature = 0.4, + maxOutputTokens = 10000 + } + }; + + var response = await _http.PostAsJsonAsync( + $"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key={apiKey}", + requestBody + ); + + if (!response.IsSuccessStatusCode) + { + var error = await response.Content.ReadAsStringAsync(); + throw new Exception($"Gemini API Error: {error}"); + } + + var jsonString = await response.Content.ReadAsStringAsync(); + + using var doc = JsonDocument.Parse(jsonString); + + var text = doc.RootElement + .GetProperty("candidates")[0] + .GetProperty("content") + .GetProperty("parts")[0] + .GetProperty("text") + .GetString(); + + return text ?? throw new Exception("No response text from Gemini."); + } + } +} diff --git a/AI/IAiClient.cs b/AI/IAiClient.cs new file mode 100644 index 0000000..26e3d45 --- /dev/null +++ b/AI/IAiClient.cs @@ -0,0 +1,7 @@ +namespace Fitness_core_BE.AI +{ + public interface IAiClient + { + Task GenerateJsonAsync(string systemPrompt, object payload); + } +} diff --git a/AI/MealPlanOrchestrator.cs b/AI/MealPlanOrchestrator.cs new file mode 100644 index 0000000..c3a728c --- /dev/null +++ b/AI/MealPlanOrchestrator.cs @@ -0,0 +1,213 @@ +using Fitness_core_BE.Helpers; +using System.Text.Json; + +namespace Fitness_core_BE.AI +{ + public class MealPlanOrchestrator + { + private readonly IAiClient _aiClient; + private readonly ModificationGuard _guard; + + public MealPlanOrchestrator( IAiClient aiClient, ModificationGuard guard) + { + _aiClient = aiClient; + _guard = guard; + } + + public async Task GeneratePlanAsync(object payload) + { + var data = DeserializePayload.Deserialize(payload); + + decimal bmi = DataExtractor.GetRequiredDecimal(data, "bmi"); + string country = DataExtractor.GetRequiredString(data, "country"); + string province = DataExtractor.GetRequiredString(data, "province"); + string goal = DataExtractor.GetRequiredString(data, "goal"); + decimal budget = DataExtractor.GetRequiredDecimal(data, "budgetPerWeek"); + int mealsPerDay = DataExtractor.GetRequiredInt(data, "mealsPerDay"); + bool isVegetarian = DataExtractor.GetRequiredBool(data, "isVegetarian"); + + // Basic validation + if (bmi < 10 || bmi > 60) + throw new Exception("Invalid BMI"); + + var systemPrompt = @" + You are a certified nutritionist. + Your response must be STRICTLY a JSON object. + + Output format rules: + 1. The first character of your response MUST be { + 2. The last character MUST be } + 3. Do not include ``` or any markdown formatting + 4. Do not include any explanation text + 5. Do not include comments + 6. Do not prefix with ""Here is"" + 7. Do not suffix with any text + + Return ONLY valid JSON. + + Structure: + { + name: string, + description: string, + mealItems: [ + { + name: string, + description: string, + calories: number, + protein: number, + carbohydrates: number, + fats: number, + servingSize: string, + mealItems: [ + { food: string, quantity: string } + ], + preparationNotes: string + } + ] + } + "; + + var jsonResponse = await _aiClient.GenerateJsonAsync( + systemPrompt, + data + ); + + // Validate JSON before returning + try + { + var parsed = JsonSerializer.Deserialize(jsonResponse); + if (parsed == null) + throw new Exception("Invalid AI JSON"); + } + catch + { + throw new Exception("AI returned invalid JSON structure"); + } + + return JsonSerializer.Deserialize(jsonResponse)!; + } + + public async Task UpdatePlanAsync(object payload) + { + var data = DeserializePayload.Deserialize(payload); + + if (!data.ContainsKey("currentPlan")) + throw new Exception("Current plan missing"); + + string modification = + DataExtractor.GetRequiredString(data, "modification"); + + _guard.Validate(modification); + + var systemPrompt = @" + You are a professional nutrition AI. + + You will receive: + 1) The current meal plan in valid JSON format. + 2) A user modification request in free text. + + Your task: + - Carefully analyze the modification request. + - Modify ONLY the specific elements explicitly requested by the user. + - Do NOT change anything else. + + VERY IMPORTANT RULES: + - Do NOT change the plan name unless the user explicitly asks. + - Do NOT change the number of meals unless requested. + - Do NOT modify calories, protein, carbs, fats unless the request requires it. + - Do NOT rewrite descriptions unless necessary for the modification. + - Do NOT change serving sizes unless required. + - Do NOT add or remove meals unless explicitly requested. + - Do NOT modify ingredients that are unrelated to the request. + - Preserve every unchanged value exactly as provided. + + Scope control: + If the request only affects one ingredient, update only that ingredient. + If the request does not affect the plan, return it unchanged. + + Preserve the exact internal structure of the meal plan. + Do NOT rename fields. + Do NOT remove required properties. + Do NOT change the JSON schema. + + Return your response strictly in this JSON format: + + { + ""reason"": string, + ""modified"": boolean, + ""currentPlan"": { + ""name"": string, + ""description"": string, + ""mealItems"": [ + { + ""name"": string, + ""description"": string, + ""calories"": number, + ""protein"": number, + ""carbohydrates"": number, + ""fats"": number, + ""servingSize"": string, + ""mealItems"": [ + { ""food"": string, ""quantity"": string } + ], + ""preparationNotes"": string + } + ] + }, + ""updatedPlan"": { + ""name"": string, + ""description"": string, + ""mealItems"": [ + { + ""name"": string, + ""description"": string, + ""calories"": number, + ""protein"": number, + ""carbohydrates"": number, + ""fats"": number, + ""servingSize"": string, + ""mealItems"": [ + { ""food"": string, ""quantity"": string } + ], + ""preparationNotes"": string + } + ] + } + } + + STRICT OUTPUT RULES: + - Return ONLY valid raw JSON. + - No markdown. + - No explanations. + - First character must be {. + - Last character must be }. + - No trailing commas. + - No extra commentary. + + If modification cannot be applied: + - Set ""modified"": false + - Keep currentPlan and updatedPlan identical. + - Explain reason clearly. + "; + var jsonResponse = await _aiClient.GenerateJsonAsync( + systemPrompt, + data + ); + + try + { + var parsed = JsonSerializer.Deserialize(jsonResponse); + if (parsed == null) + throw new Exception("Invalid JSON"); + } + catch + { + throw new Exception("AI returned invalid JSON"); + } + + return JsonSerializer.Deserialize(jsonResponse)!; + } + + } + +} diff --git a/AI/ModificationGuard.cs b/AI/ModificationGuard.cs new file mode 100644 index 0000000..90a1975 --- /dev/null +++ b/AI/ModificationGuard.cs @@ -0,0 +1,47 @@ +namespace Fitness_core_BE.AI +{ + public class ModificationGuard + { + private static readonly string[] AllowedKeywords = + { + "replace", + "remove", + "allergy", + "change", + "vegetarian", + "calorie", + "protein", + "carb", + "fat", + "meal", + "ingredient" + }; + + private static readonly string[] BlockedPatterns = + { + "ignore previous instructions", + "system prompt", + "act as", + "bypass", + "developer mode", + "jailbreak" + }; + + public void Validate(string message) + { + if (string.IsNullOrWhiteSpace(message)) + throw new Exception("Modification cannot be empty."); + + if (message.Length > 500) + throw new Exception("Modification too long."); + + var lower = message.ToLower(); + + if (BlockedPatterns.Any(p => lower.Contains(p))) + throw new Exception("Invalid modification request."); + + //if (!AllowedKeywords.Any(k => lower.Contains(k))) + // throw new Exception("Only meal plan modifications are allowed."); + } + } +} diff --git a/AI/WorkoutPlanOrchestrator.cs b/AI/WorkoutPlanOrchestrator.cs new file mode 100644 index 0000000..d912f60 --- /dev/null +++ b/AI/WorkoutPlanOrchestrator.cs @@ -0,0 +1,229 @@ +using Fitness_core_BE.Helpers; +using System.Text.Json; + +namespace Fitness_core_BE.AI +{ + public class WorkoutPlanOrchestrator + { + private readonly IAiClient _aiClient; + private readonly ModificationGuard _guard; + + public WorkoutPlanOrchestrator(IAiClient aiClient, ModificationGuard guard) + { + _aiClient = aiClient; + _guard = guard; + } + + public async Task GeneratePlanAsync(object payload) + { + var data = DeserializePayload.Deserialize(payload); + + string fitnessLevel = DataExtractor.GetRequiredString(data, "fitnessLevel"); + string goal = DataExtractor.GetRequiredString(data, "goal"); + int daysPerWeek = DataExtractor.GetRequiredInt(data, "daysPerWeek"); + string availableEquipment = DataExtractor.GetRequiredString(data, "availableEquipment"); + int durationMinutes = DataExtractor.GetRequiredInt(data, "durationPerSession"); + + // Optional fields + string? injuryOrLimitations = data.ContainsKey("injuryOrLimitations") + ? data["injuryOrLimitations"].GetString() + : null; + + // Basic validation + if (daysPerWeek < 1 || daysPerWeek > 7) + throw new Exception("Days per week must be between 1 and 7"); + + if (durationMinutes < 15 || durationMinutes > 180) + throw new Exception("Duration must be between 15 and 180 minutes"); + + + //bmi, body shape , target , should check with tartget and body shape matching, time peiod, Continuose-week days,,, whether going to Gym or not, Injuries, any seriose conditions, + + var systemPrompt = @" + You are a certified personal trainer and fitness expert. + Your response must be STRICTLY a JSON object. + + Output format rules: + 1. The first character of your response MUST be { + 2. The last character MUST be } + 3. Do not include ``` or any markdown formatting + 4. Do not include any explanation text + 5. Do not include comments + 6. Do not prefix with ""Here is"" + 7. Do not suffix with any text + + Return ONLY valid JSON. + + Structure: + { + ""name"": string, + ""description"": string, + ""workoutItems"": [ + { + ""exerciseDay"": string, + ""description"": string, + ""exercises"": { + ""Exercise Name"": ""sets x reps or duration"" + }, + ""duration"": string (total session time including warmup and cooldown, with units), + ""wormUp"": string (warmup instructions with an explicit time and unit), + ""cooldown"": string (cooldown instructions with an explicit time and unit), + ""howToDo"": string (JSON string with rest periods and notes), + ""doURL"": string (exercise reference URL) + } + ] + } + + Important guidelines: + - Create a workout plan based on the user's fitness level, goal, and available equipment + - Include appropriate exercises for the specified number of days per week + - Each workout must fit within the specified duration + - Set ""duration"" to the requested total duration per session (for example, ""45 minutes"") + - ""wormUp"" and ""cooldown"" must each include an explicit duration such as ""5 minutes"" + - The main workout time plus wormUp time plus cooldown time must equal the total ""duration"" + - The ""exercises"" field should be an object with exercise names as keys and sets/reps as values + - The ""howToDo"" field should be a JSON string containing warmup, rest periods, and notes + - Consider any injury or limitations mentioned + - Provide realistic sets, reps, and rest periods based on fitness level + - Include proper progression and variety + "; + + var jsonResponse = await _aiClient.GenerateJsonAsync( + systemPrompt, + data + ); + + // Validate JSON before returning + try + { + var parsed = JsonSerializer.Deserialize(jsonResponse); + if (parsed == null) + throw new Exception("Invalid AI JSON"); + } + catch + { + throw new Exception("AI returned invalid JSON structure"); + } + + return JsonSerializer.Deserialize(jsonResponse)!; + } + + public async Task UpdatePlanAsync(object payload) + { + var data = DeserializePayload.Deserialize(payload); + + if (!data.ContainsKey("currentWorkoutPlan")) + throw new Exception("Current plan missing"); + + string modification = DataExtractor.GetRequiredString(data, "modification"); + + _guard.Validate(modification); + + var systemPrompt = @" + You are a professional fitness AI. + + You will receive: + 1) The current workout plan in valid JSON format. + 2) A user modification request in free text. + 3) BMI, body shape, target, time period, continuous workout days, whether going to gym or not, injuries, any seriose conditions. + + Your task: + - Carefully analyze the modification request. + - Modify ONLY the specific elements explicitly requested by the user. + - Do NOT change anything else. + + VERY IMPORTANT RULES: + - Do NOT change the plan name unless the user explicitly asks. + - Do NOT change the number of workout days unless requested. + - Do NOT modify exercises that are not mentioned in the request. + - Do NOT rewrite descriptions unless necessary for the modification. + - Do NOT change sets, reps, or rest periods unless the request requires it. + - Do NOT add or remove exercises unless explicitly requested. + - Do NOT modify workout structure that is unrelated to the request. + - Preserve every unchanged value exactly as provided. + - Analyze the user's BMI, body shape, target, time period, continuous workout days, gym access, injuries, and any seriose conditions. + - Stricly focus if mentioned any seriose health conditions or injuries and do not suggest any exercise that can cause harm to the user. + - If the modification request is not logical or contradicts fitness principles, do not apply it and explain why in the reason field. + + + Scope control: + If the request only affects one exercise, update only that exercise. + If the request does not affect the plan, return it unchanged. + + Preserve the exact internal structure of the workout plan. + Do NOT rename fields. + Do NOT remove required properties. + Do NOT change the JSON schema. + + Return your response strictly in this JSON format: + + { + ""reason"": string, + ""modified"": boolean, + ""currentPlan"": { + ""name"": string, + ""description"": string, + ""workoutItems"": [ + { + ""exerciseDay"": string, + ""description"": string, + ""exercises"": { + ""Exercise Name"": ""sets x reps or duration"" + }, + ""howToDo"": string, + ""doURL"": string + } + ] + }, + ""updatedPlan"": { + ""name"": string, + ""description"": string, + ""workoutItems"": [ + { + ""exerciseDay"": string, + ""description"": string, + ""exercises"": { + ""Exercise Name"": ""sets x reps or duration"" + }, + ""howToDo"": string, + ""doURL"": string + } + ] + } + } + + STRICT OUTPUT RULES: + - Return ONLY valid raw JSON. + - No markdown. + - No explanations. + - First character must be {. + - Last character must be }. + - No trailing commas. + - No extra commentary. + + If modification cannot be applied: + - Set ""modified"": false + - Keep currentPlan and updatedPlan identical. + - Explain reason clearly. + "; + + var jsonResponse = await _aiClient.GenerateJsonAsync( + systemPrompt, + data + ); + + try + { + var parsed = JsonSerializer.Deserialize(jsonResponse); + if (parsed == null) + throw new Exception("Invalid JSON"); + } + catch + { + throw new Exception("AI returned invalid JSON"); + } + + return JsonSerializer.Deserialize(jsonResponse)!; + } + } +} diff --git a/Auth/AgentApiKeyAuthenticationHandler.cs b/Auth/AgentApiKeyAuthenticationHandler.cs new file mode 100644 index 0000000..7cb1c07 --- /dev/null +++ b/Auth/AgentApiKeyAuthenticationHandler.cs @@ -0,0 +1,79 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Interfaces; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; + +namespace Fitness_core_BE.Auth +{ + /// + /// Constants for the agent API-key authentication scheme / authorization policy. + /// + public static class AgentApiKeyDefaults + { + public const string AuthenticationScheme = "AgentApiKey"; + public const string PolicyName = "AgentApiKey"; + public const string HeaderName = "X-Api-Key"; + + // Claim types used to carry the resolved agent + client server-side. + public const string AgentIdClaim = "AgentId"; + public const string ClientIdClaim = "FKClientId"; + } + + public class AgentApiKeyAuthenticationOptions : AuthenticationSchemeOptions { } + + /// + /// Authenticates on-prem agents using the high-entropy key sent in the X-Api-Key header. + /// The key is hashed and matched against an active Agent. On success it exposes the + /// agent's FKClientId and AgentId via claims + HttpContext.Items so downstream code never + /// has to trust client/agent identifiers coming from the request body. + /// + public class AgentApiKeyAuthenticationHandler : AuthenticationHandler + { + private readonly IAgentRepo _agentRepo; + + public AgentApiKeyAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + IAgentRepo agentRepo) + : base(options, logger, encoder) + { + _agentRepo = agentRepo; + } + + protected override async Task HandleAuthenticateAsync() + { + if (!Request.Headers.TryGetValue(AgentApiKeyDefaults.HeaderName, out var headerValues)) + return AuthenticateResult.NoResult(); + + var apiKey = headerValues.FirstOrDefault(); + if (string.IsNullOrWhiteSpace(apiKey)) + return AuthenticateResult.Fail("Missing API key."); + + var hash = ApiKeyHelper.HashApiKey(apiKey); + var agent = await _agentRepo.GetByApiKeyHashAsync(hash); + + if (agent == null || !agent.IsActive) + return AuthenticateResult.Fail("Invalid API key."); + + // Expose resolved identity server-side (never trust the body for these). + Context.Items[AgentApiKeyDefaults.AgentIdClaim] = agent.AgentId.ToString(); + Context.Items[AgentApiKeyDefaults.ClientIdClaim] = agent.FKClientId.ToString(); + + var claims = new[] + { + new Claim(AgentApiKeyDefaults.AgentIdClaim, agent.AgentId.ToString()), + new Claim(AgentApiKeyDefaults.ClientIdClaim, agent.FKClientId.ToString()), + new Claim(ClaimTypes.NameIdentifier, agent.AgentId.ToString()) + }; + + var identity = new ClaimsIdentity(claims, Scheme.Name); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, Scheme.Name); + + return AuthenticateResult.Success(ticket); + } + } +} diff --git a/Auth/UserJwtAuthenticationHandler.cs b/Auth/UserJwtAuthenticationHandler.cs new file mode 100644 index 0000000..1b7b1f0 --- /dev/null +++ b/Auth/UserJwtAuthenticationHandler.cs @@ -0,0 +1,97 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace Fitness_core_BE.Auth +{ + public static class UserJwtDefaults + { + public const string AuthenticationScheme = "UserJwtCookie"; + } + + public sealed class UserJwtAuthenticationOptions : AuthenticationSchemeOptions { } + + /// + /// Authenticates browser SignalR connections from the existing httpOnly + /// access_token cookie. MVC controllers retain their existing action filter. + /// + public sealed class UserJwtAuthenticationHandler + : AuthenticationHandler + { + private readonly IConfiguration _configuration; + + public UserJwtAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + IConfiguration configuration) + : base(options, logger, encoder) + { + _configuration = configuration; + } + + protected override Task HandleAuthenticateAsync() + { + var token = Request.Cookies["access_token"]; + if (string.IsNullOrWhiteSpace(token)) + return Task.FromResult(AuthenticateResult.NoResult()); + + try + { + var xmlKey = _configuration["Jwt:PublicKey"]; + if (string.IsNullOrWhiteSpace(xmlKey)) + throw new SecurityTokenException("JWT public key is not configured."); + + using var rsa = RSA.Create(); + rsa.FromXmlString(xmlKey); + var parameters = rsa.ExportParameters(false); + var key = new RsaSecurityKey(parameters) + { + KeyId = ComputeKeyId(parameters) + }; + + var principal = new JwtSecurityTokenHandler().ValidateToken( + token, + new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = key, + ValidateIssuer = true, + ValidIssuer = _configuration["Jwt:Issuer"], + ValidateAudience = true, + ValidAudience = _configuration["Jwt:Audience"], + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(2) + }, + out _); + + var identity = new ClaimsIdentity( + principal.Claims, + UserJwtDefaults.AuthenticationScheme); + var ticket = new AuthenticationTicket( + new ClaimsPrincipal(identity), + UserJwtDefaults.AuthenticationScheme); + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + catch (Exception ex) + { + return Task.FromResult( + AuthenticateResult.Fail($"Invalid access token: {ex.Message}")); + } + } + + private static string ComputeKeyId(RSAParameters parameters) + { + var modulus = parameters.Modulus ?? Array.Empty(); + var exponent = parameters.Exponent ?? Array.Empty(); + var material = + $"{Convert.ToBase64String(modulus)}.{Convert.ToBase64String(exponent)}"; + return Base64UrlEncoder.Encode( + SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(material))); + } + } +} diff --git a/CentralDbContext.cs b/CentralDbContext.cs new file mode 100644 index 0000000..93aea5b --- /dev/null +++ b/CentralDbContext.cs @@ -0,0 +1,223 @@ +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Fitness_core_BE +{ + // Central database: data that is inherently cross-tenant -- customer identity, the + // client/gym registry, admin data, admin-authored plan templates, and platform billing. + // See docs/AppDBContext split rationale in the migration plan. Row-split entities + // (MealPlan/WorkoutPlan family, PaymentTransactions) also live here for template / + // platform-billing rows -- TenantDbContext holds the same entity types for the + // client-owned / member-payment rows. + public class CentralDbContext : DbContext + { + public CentralDbContext(DbContextOptions options) : base(options) { } + + // Identity & tenant registry + public DbSet Users => Set(); + public DbSet Admins => Set(); + public DbSet Customer => Set(); + public DbSet Client => Set(); + public DbSet ClientBusiness => Set(); + // Agent/PairingCode stay central: Agent authenticates purely via X-Api-Key (no JWT at + // all), and PairingCode is created by platform Admin and consumed by an anonymous agent + // enroll call -- neither has any tenant-scoping mechanism to key off of. + public DbSet Agents => Set(); + public DbSet PairingCodes => Set(); + + // Background-job run history (SubscriptionRenewalJob, etc.) -- central because it's + // not gym-operational data, just an audit trail of what each sweep did. + public DbSet JobRunLogs => Set(); + + // Customer-follows-you-everywhere data + public DbSet CustomerHealth => Set(); + public DbSet CustomerTargets => Set(); + public DbSet CusWeightLossProgress => Set(); + public DbSet CustomerNotifications => Set(); + + // Admin-authored plan templates (IsTemplate=true / FKClientId=null rows only) + public DbSet MealCategories => Set(); + public DbSet MealPlans => Set(); + public DbSet Meal => Set(); + public DbSet WorkoutCategories => Set(); + public DbSet WorkoutPlans => Set(); + public DbSet WorkoutItems => Set(); + public DbSet WorkoutExercises => Set(); + + // Support / global catalog + public DbSet Tickets => Set(); + public DbSet TicketCategories => Set(); + public DbSet TicketMessages => Set(); + public DbSet NavItems => Set(); + public DbSet UserDocuments => Set(); + public DbSet DocumentTypes => Set(); + + // Platform billing (HexaFitness -> gym) + public DbSet ClientInvoices => Set(); + public DbSet ClientCard => Set(); + public DbSet ClientInvoiceItems => Set(); + public DbSet ClientSubscriptions => Set(); + public DbSet ClientSubItems => Set(); + public DbSet ClientPackages => Set(); + public DbSet ClientPayoutKycs => Set(); + public DbSet ClientWallets => Set(); + public DbSet WalletLedgerEntries => Set(); + public DbSet PayoutBatches => Set(); + public DbSet DevicePricingConfigs => Set(); + public DbSet ClientDeviceBillings => Set(); + // Row-split: only rows with FK_ClientInvoiceId set (platform billing payments) live + // here -- member payments (FK_CustomerInvoiceId set) live in TenantDbContext instead. + public DbSet PaymentTransactions => Set(); + + // Ambiguous scope today (no FKClientId on the model) -- kept central pending the + // Phase-2 confirmation spike called out in the migration plan. + public DbSet FeedPosts => Set(); + public DbSet FeedLikes => Set(); + public DbSet ActivityLogs => Set(); + public DbSet Chat => Set(); + public DbSet ChatMessages => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Tenant-only entity types must never be part of this model at all. Ignoring + // individual navigations (below) isn't enough on its own: an unignored tenant + // collection/reference nav anywhere in the reachable graph (e.g. + // CustomerSubscription.AutoChargeCard -> CustomerCards, CustomerInvoice. + // CustomerSubscription) pulls the whole type into this context's model via EF's + // reachability-based discovery, which can then invent a bogus shadow-FK + // relationship between that type and whatever OTHER entity also happens to have + // a real FK to it -- breaking queries with "Unknown column" once EF tries to + // select a column that doesn't exist in the central schema. Same failure mode + // proven and fixed on the TenantDbContext side (see its OnModelCreating) -- + // mirrored here in the opposite direction. Row-split entities (MealPlan/ + // WorkoutPlan family, PaymentTransaction) are deliberately NOT in this list -- + // they have real DbSets in both contexts. + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + + modelBuilder.Entity() + .HasIndex(a => a.ApiKeyHash) + .IsUnique(); + modelBuilder.Entity() + .HasIndex(r => new { r.JobName, r.RunAtUtc }); + + // Row-split entities: no FKClientId relationship enforced here (template rows + // have FKClientId = null; the rare non-null value on a row that ended up in this + // DB isn't a real cross-schema FK once tenants split out). + modelBuilder.Entity().Ignore(p => p.Client); + modelBuilder.Entity().Ignore(p => p.Client); + modelBuilder.Entity().Ignore(p => p.Client); + modelBuilder.Entity().Ignore(p => p.Customer); + modelBuilder.Entity().Ignore(p => p.CustomerCard); + modelBuilder.Entity().Ignore(p => p.CustomerInvoice); + + // Customer's collection navigations into tenant-only entities -- not resolvable + // here; load those separately from TenantDbContext and merge in the caller. + modelBuilder.Entity().Ignore(c => c.CustomerWorkouts); + modelBuilder.Entity().Ignore(c => c.CustomerMeals); + modelBuilder.Entity().Ignore(c => c.CustomerSubscriptions); + + modelBuilder.Entity().HasData( + new NavItem { Key = "dashboard", Label = "Dashboard", ShortLabel = "Home", Href = "/dashboard", Icon = "LayoutDashboard", SortOrder = 0, IsOwnerOnly = false }, + new NavItem { Key = "attendance", Label = "Attendance", Href = "/dashboard/attendance", Icon = "CalendarCheck", SortOrder = 1, IsOwnerOnly = false }, + new NavItem { Key = "members", Label = "Members", Href = "/dashboard/members", Icon = "Users", SortOrder = 2, IsOwnerOnly = false }, + new NavItem { Key = "memberships", Label = "Memberships", Href = "/dashboard/memberships", Icon = "CreditCard", SortOrder = 3, IsOwnerOnly = false }, + new NavItem { Key = "bookings", Label = "Bookings", Href = "/dashboard/bookings", Icon = "CalendarClock", SortOrder = 4, IsOwnerOnly = false }, + new NavItem { Key = "customer-payments", Label = "Customer Payments", ShortLabel = "Payments", Href = "/dashboard/customer-payments", Icon = "Receipt", SortOrder = 5, IsOwnerOnly = false }, + new NavItem { Key = "workout-plans", Label = "Workout Plans", Href = "/dashboard/workout-plans", Icon = "Activity", SortOrder = 7, IsOwnerOnly = false }, + new NavItem { Key = "meal-plans", Label = "Meal Plans", Href = "/dashboard/meal-plans", Icon = "Utensils", SortOrder = 8, IsOwnerOnly = false }, + new NavItem { Key = "staff", Label = "Staff", Href = "/dashboard/staff", Icon = "UsersRound", SortOrder = 9, IsOwnerOnly = false }, + new NavItem { Key = "feed", Label = "Feed", Href = "/dashboard/feed", Icon = "Rss", SortOrder = 10, IsOwnerOnly = false }, + new NavItem { Key = "tickets", Label = "Tickets", Href = "/dashboard/tickets", Icon = "TicketIcon", SortOrder = 11, IsOwnerOnly = false }, + new NavItem { Key = "reports", Label = "Reports", Href = "/dashboard/reports", Icon = "BarChart3", SortOrder = 12, IsOwnerOnly = false }, + new NavItem { Key = "settings", Label = "Gym Profile", Href = "/dashboard/settings", Icon = "Settings", SortOrder = 13, IsOwnerOnly = true } + ); + + modelBuilder.Entity().HasData( + new DocumentTypes { DocumentId = 1001, DocumentType = "NIC_FRONT" }, + new DocumentTypes { DocumentId = 1002, DocumentType = "NIC_BACK" }, + new DocumentTypes { DocumentId = 1003, DocumentType = "BR_CERTIFICATE" }, + new DocumentTypes { DocumentId = 1004, DocumentType = "BANK_PROOF" }, + // Images/video attached to a feed post -- FeedService.CreateFeed tags its + // uploads with this id, so the row has to exist or the UserDocuments FK fails. + new DocumentTypes { DocumentId = 1005, DocumentType = "FEED_POST" }, + // Profile picture uploads (CustomerController/ClientController) tag with this id. + new DocumentTypes { DocumentId = 1006, DocumentType = "PROFILE_IMAGE" } + ); + + modelBuilder.Entity().HasData( + new User + { + UserId = AIAgents.MealAgentId, + UserName = "MealPlan AI", + Email = "meal.ai@system.local", + PhoneNumber = null, + IsActive = true, + CreateDate = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc) + }, + new User + { + UserId = AIAgents.WorkoutAgentId, + UserName = "WorkoutPlan AI", + Email = "workout.ai@system.local", + PhoneNumber = null, + IsActive = true, + CreateDate = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc) + } + ); + + // Platform-wide "coach guided" suggested workout plans -- admin templates. + modelBuilder.Entity().HasData(Data.WorkoutPlanSeedData.Categories()); + modelBuilder.Entity().HasData(Data.WorkoutPlanSeedData.Plans()); + modelBuilder.Entity().HasData(Data.WorkoutPlanSeedData.Items()); + modelBuilder.Entity().HasData(Data.WorkoutPlanSeedData.Exercises()); + } + } + + public class CentralDbContextFactory : IDesignTimeDbContextFactory + { + public CentralDbContext CreateDbContext(string[] args) + { + var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development"; + var config = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false) + .AddJsonFile($"appsettings.{env}.json", optional: true) + .Build(); + + var optionsBuilder = new DbContextOptionsBuilder(); + var connectionString = config.GetConnectionString("DefaultConnection"); + optionsBuilder.UseMySql(connectionString, new MySqlServerVersion(new Version(8, 0, 0))); + + return new CentralDbContext(optionsBuilder.Options); + } + } +} diff --git a/Controllers/APIController.cs b/Controllers/APIController.cs new file mode 100644 index 0000000..93514ca --- /dev/null +++ b/Controllers/APIController.cs @@ -0,0 +1,365 @@ +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Models.IOs; +using Fitness_core_BE.Services.FunctionHandler; +using Fitness_core_BE.Services.Document; +using Fitness_core_BE.Interfaces.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.StaticFiles; +using System.Text.Json; +using static Fitness_core_BE.Helpers.JwtMiddleware; +using Fitness_core_BE.DTOs; + +namespace Fitness_core_BE.Controllers +{ + /// + /// Main API Controller for handling various operations + /// + [Route("api")] + [ApiController] + [Produces("application/json")] + [EnableCors("AllowAll")] + public class APIController : ControllerBase + { + private readonly TicketFuncHandler _ticketFuncHandler; + private readonly ActivityLogFuncHandler _activityLogFuncHandler; + private readonly ClientFuncHandler _clientFuncHandler; + private readonly StaffFunctionHandler _staffFuncHandler; + private readonly ClientInvoiceFunctionHandler _clientInvoiceFuncHandler; + private readonly CustomerFunctionHandler _customerFuncHandler; + private readonly CustomerInvoiceFunctionHandler _customerInvoiceFuncHandler; + private readonly CustomerPlanFunctionHandler _customerPlanFuncHandler; + private readonly CustomerSubscriptionFunctionHandler _customerSubscriptionFuncHandler; + private readonly MealFunctionHandler _mealFuncHandler; + private readonly AuthFuncHandler _authFuncHandler; + private readonly WorkoutFunctionHandler _workoutFuncHandler; + private readonly DocumentFunctionHandler _documentFuncHandler; + private readonly ChatFunctionHandler _chatFuncHandler; + private readonly FeedFuncHandler _feedFuncHandler; + private readonly AccessDeviceFunctionHandler _accessDeviceFuncHandler; + private readonly AttendanceFunctionHandler _attendanceFuncHandler; + private readonly DocumentFunctionHandler _docHandler; + private readonly IDocumentService _docService; + private readonly UserRegisterHandler _userRegisterHandler; + private readonly ClientStatFuncHandler _clientStatFuncHandler; + private readonly PaymentFunctionHandler _paymentFuncHandler; + + public APIController( + TicketFuncHandler ticketFuncHandler, + ActivityLogFuncHandler activityLogFuncHandler, + ClientFuncHandler clientFuncHandler, + AuthFuncHandler authFuncHandler, + StaffFunctionHandler staffFuncHandler, + ClientInvoiceFunctionHandler clientInvoiceFuncHandler, + CustomerFunctionHandler customerFuncHandler, + CustomerInvoiceFunctionHandler customerInvoiceFuncHandler, + CustomerPlanFunctionHandler customerPlanFuncHandler, + CustomerSubscriptionFunctionHandler customerSubscriptionFuncHandler, + MealFunctionHandler mealFuncHandler, + WorkoutFunctionHandler workoutFuncHandler, + DocumentFunctionHandler documentFuncHandler, + FeedFuncHandler feedFuncHandler, + AccessDeviceFunctionHandler accessDeviceFuncHandler, + AttendanceFunctionHandler attendanceFuncHandler, + ChatFunctionHandler chatFunctionHandler, + DocumentFunctionHandler docHandler, + IDocumentService docService, + UserRegisterHandler userRegisterHandler, + ClientStatFuncHandler clientStatFuncHandler, + PaymentFunctionHandler paymentFuncHandler) + { + _ticketFuncHandler = ticketFuncHandler; + _activityLogFuncHandler = activityLogFuncHandler; + _clientFuncHandler = clientFuncHandler; + _staffFuncHandler = staffFuncHandler; + _clientInvoiceFuncHandler = clientInvoiceFuncHandler; + _customerFuncHandler = customerFuncHandler; + _customerInvoiceFuncHandler = customerInvoiceFuncHandler; + _customerPlanFuncHandler = customerPlanFuncHandler; + _customerSubscriptionFuncHandler = customerSubscriptionFuncHandler; + _mealFuncHandler = mealFuncHandler; + _authFuncHandler = authFuncHandler; + _workoutFuncHandler = workoutFuncHandler; + _documentFuncHandler = documentFuncHandler; + _feedFuncHandler = feedFuncHandler; + _accessDeviceFuncHandler = accessDeviceFuncHandler; + _attendanceFuncHandler = attendanceFuncHandler; + _chatFuncHandler = chatFunctionHandler; + _docHandler = docHandler; + _docService = docService; + _userRegisterHandler = userRegisterHandler; + _clientStatFuncHandler = clientStatFuncHandler; + _paymentFuncHandler = paymentFuncHandler; + } + + /// + /// Check API health status + /// + /// API status and current timestamp + /// Returns API status + [HttpGet("status")] + [CustomMiddleware] + [TenantResolution] + [ProducesResponseType(StatusCodes.Status200OK)] + public IActionResult GetStatus() + { + return Ok(new { status = "API is running", timestamp = System.DateTime.UtcNow }); + } + + [HttpPost("ticket")] + [CustomMiddleware] + [TenantResolution] + public async Task ExecuteTicket([FromBody] ApiRequest request) + { + var response = await _ticketFuncHandler.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + [HttpPost("activitylog")] + public async Task ExecuteActivityLog([FromBody] ApiRequest request) + { + var response = await _activityLogFuncHandler.ExecuteAsync(request.FunctionName, request.Payload, HttpContext); + return Ok(new ApiResponse + { + StatusCode = 200, + Message = "Success", + Data = response + }); + } + + + [HttpPost("client")] + [CustomMiddleware] + [TenantResolution] + public async Task ExecuteClient([FromBody] ApiRequest request) + { + var response = await _clientFuncHandler.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + [HttpPost("auth")] + public async Task ExecuteAuth([FromBody] ApiRequest request) + { + var response = await _authFuncHandler.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + [HttpPost("VerifyOTP")] + [CustomMiddleware] + [TenantResolution] + public async Task ForgotPassword([FromBody] ApiRequest request) + { + request.FunctionName = "VerifyOTP"; + var response = await _authFuncHandler.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + [HttpPost("refreshToken")] + [AllowAnonymous] + public async Task RefreshToken([FromBody] ApiRequest request) + { + request.FunctionName = "RefreshToken"; + var response = await _authFuncHandler.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + // Anonymous so logout still clears cookies even if the access token has expired. + [HttpPost("logout")] + [AllowAnonymous] + public async Task Logout([FromBody] ApiRequest? request) + { + var logoutRequest = request ?? new ApiRequest(); + logoutRequest.FunctionName = "LogOut"; + var response = await _authFuncHandler.Execute(logoutRequest, HttpContext); + return StatusCode(response.StatusCode, response); + } + + + + [HttpPost("staff")] + [CustomMiddleware] + [TenantResolution] + public async Task ExecuteStaff([FromBody] ApiRequest request) + { + var response = await _staffFuncHandler.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + [HttpPost("clientinvoice")] + [CustomMiddleware] + [TenantResolution] + public async Task ExecuteClientInvoice([FromBody] ApiRequest request) + { + var response = await _clientInvoiceFuncHandler.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + [HttpPost("customer")] + [CustomMiddleware] + [TenantResolution] + public async Task ExecuteCustomer([FromBody] ApiRequest request) + { + var response = await _customerFuncHandler.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + [HttpPost("customerinvoice")] + [CustomMiddleware] + [TenantResolution] + public async Task ExecuteCustomerInvoice([FromBody] ApiRequest request) + { + var response = await _customerInvoiceFuncHandler.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + [HttpPost("customerplan")] + [CustomMiddleware] + [TenantResolution] + public async Task ExecuteCustomerPlan([FromBody] ApiRequest request) + { + var response = await _customerPlanFuncHandler.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + [HttpPost("customersubscription")] + [CustomMiddleware] + [TenantResolution] + public async Task ExecuteCustomerSubscription([FromBody] ApiRequest request) + { + var response = await _customerSubscriptionFuncHandler.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + [HttpPost("meal")] + [CustomMiddleware] + [TenantResolution] + public async Task ExecuteMeal([FromBody] ApiRequest request) + { + var response = await _mealFuncHandler.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + [HttpPost("workout")] + [CustomMiddleware] + [TenantResolution] + public async Task ExecuteWorkout([FromBody] ApiRequest request) + { + var response = await _workoutFuncHandler.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + [HttpPost("document")] + [CustomMiddleware] + [TenantResolution] + public async Task ExecuteDocument([FromBody] ApiRequest request) + { + var response = await _documentFuncHandler.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + [HttpPost("feed")] + [CustomMiddleware] + [TenantResolution] + public async Task ExecuteFeed([FromBody] ApiRequest request) + { + var response = await _feedFuncHandler.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + [HttpPost("accessdevice")] + public async Task ExecuteAccessDevice([FromBody] ApiRequest request) + { + var response = await _accessDeviceFuncHandler.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + [HttpPost("attendance")] + [CustomMiddleware] + [TenantResolution] + public async Task ExecuteAttendance([FromBody] ApiRequest request) + { + var response = await _attendanceFuncHandler.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + [HttpPost("chat")] + [CustomMiddleware] + [TenantResolution] + public async Task ExecuteChat([FromBody] ApiRequest request) + { + var response = await _chatFuncHandler.Execute(request); + return StatusCode(response.StatusCode, response); + } + [HttpPost("initChat")] + [CustomMiddleware] + [TenantResolution] + public async Task InitChat([FromBody] ApiRequest request) + { + var response = await _chatFuncHandler.Execute(request); + return StatusCode(response.StatusCode, response); + } + + [HttpPost("payment")] + [CustomMiddleware] + [TenantResolution] + public async Task ExecutePayment([FromBody] ApiRequest request) + { + var response = await _paymentFuncHandler.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + [HttpPost("clientstat")] + [CustomMiddleware] + [TenantResolution] + public async Task ExecuteClientStat([FromBody] ApiRequest request) + { + var response = await _clientStatFuncHandler.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + [HttpPost("register")] + public async Task ExecuteRegsiter([FromBody] ApiRequest request) + { + var response = await _userRegisterHandler.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + + [HttpGet("docService/{docId}")] + [AllowAnonymous] + public async Task GetDocument(string docId) + { + if (!int.TryParse(docId, out int documentId)) + return BadRequest("Invalid Document ID format."); + + + DocumentDownloadModel result = await _docService.GetFileBytesById(documentId); + + if (result == null) + return NotFound("Document not found"); + + try + { + byte[] fileBytes = result.FileData; + string fileName = result.FileName; + + var provider = new FileExtensionContentTypeProvider(); + if (!provider.TryGetContentType(fileName, out var contentType)) + { + contentType = "application/octet-stream"; + } + + return File(fileBytes, contentType, fileName); + } + catch (Exception ex) + { + return BadRequest($"Error processing document: {ex.Message}"); + } + } + + + + } +} diff --git a/Controllers/AdminBackgroundJobsController.cs b/Controllers/AdminBackgroundJobsController.cs new file mode 100644 index 0000000..7ceb975 --- /dev/null +++ b/Controllers/AdminBackgroundJobsController.cs @@ -0,0 +1,145 @@ +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Infra.Tenancy; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models.IOs; +using Fitness_core_BE.Services; +using Microsoft.AspNetCore.Mvc; +using static Fitness_core_BE.Helpers.JwtMiddleware; +using static Fitness_core_BE.Helpers.ApiResponseHelper; + +namespace Fitness_core_BE.Controllers +{ + // Platform-Admin-only visibility into every background job, and a manual "run it now" + // trigger for each -- one home for this instead of a new controller per job. Each job's + // actual algorithm stays in its own runner class (SubscriptionRenewalRunner, + // OverdueLockRunner); this controller only reuses those, same as the hosted services do. + // Same [CustomMiddleware][TenantResolution] gating as the rest of AdminController. + [ApiController] + [Route("api/admin/background-jobs")] + [CustomMiddleware] + [TenantResolution] + public class AdminBackgroundJobsController : ControllerBase + { + private const string RenewalJobName = "SubscriptionRenewalJob"; + + private readonly TenantSchemaIterator _iterator; + private readonly SubscriptionRenewalRunner _renewalRunner; + private readonly OverdueLockRunner _overdueLockRunner; + private readonly IJobRunHistory _history; + private readonly IOverdueLockRepository _overdueLockRepository; + // Normal DI-resolved CentralDbContext is fine here (unlike in a background job) -- + // this runs inside a real HTTP request, and central is one fixed database regardless + // of tenant, so there's no per-request tenant resolution involved either way. + private readonly CentralDbContext _central; + + public AdminBackgroundJobsController( + TenantSchemaIterator iterator, + SubscriptionRenewalRunner renewalRunner, + OverdueLockRunner overdueLockRunner, + IJobRunHistory history, + IOverdueLockRepository overdueLockRepository, + CentralDbContext central) + { + _iterator = iterator; + _renewalRunner = renewalRunner; + _overdueLockRunner = overdueLockRunner; + _history = history; + _overdueLockRepository = overdueLockRepository; + _central = central; + } + + [HttpGet("renewal/runs")] + public async Task>>> GetRenewalRuns( + [FromQuery] int take = 20, CancellationToken ct = default) + { + return Response(await _history.GetRecentAsync(_central, RenewalJobName, take, ct)); + } + + // Runs a full sweep across every provisioned tenant schema immediately, rather than + // waiting for the next daily (01:00 Sri Lanka time) tick. Uses the exact same + // SubscriptionRenewalRunner the hosted service itself calls -- no separate/duplicated + // renewal logic. Optional clientId scopes the sweep to one gym (e.g. re-running just for + // a client that failed, or testing against one tenant without touching every other gym). + [HttpPost("renewal/trigger")] + public async Task>>> TriggerRenewalNow( + [FromQuery] Guid? clientId, CancellationToken ct) + { + var now = DateTime.UtcNow; + var results = new List(); + + var targets = clientId.HasValue + ? SingleClientAsync(clientId.Value, ct) + : _iterator.GetProvisionedRegistrationsAsync(ct); + + await foreach (var (targetClientId, schemaName) in targets) + { + TenantSchemaContext? schemaCtx = null; + try + { + schemaCtx = _iterator.OpenSchema(targetClientId, schemaName); + var result = await _renewalRunner.RunForSchemaAsync(schemaCtx, now, ct); + results.Add(result); + + await _history.RecordAsync(schemaCtx.Central, new JobRunRecord( + RenewalJobName, targetClientId, now, + result.Failed > 0 ? "PartialFailure" : "Success", + result.Renewed, + result.Failed > 0 ? string.Join("; ", result.Notes) : null), ct); + } + catch (Exception ex) + { + results.Add(new SubscriptionRenewalRunResult(targetClientId, 0, 0, 1, new[] { ex.Message })); + schemaCtx ??= _iterator.OpenSchema(targetClientId, schemaName); + await _history.RecordAsync(schemaCtx.Central, new JobRunRecord(RenewalJobName, targetClientId, now, "Failed", 0, ex.Message), ct); + } + finally + { + if (schemaCtx != null) + await schemaCtx.DisposeAsync(); + } + } + + return Response(results); + } + + private async IAsyncEnumerable<(Guid ClientId, string SchemaName)> SingleClientAsync( + Guid clientId, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) + { + await using var schemaCtx = await _iterator.OpenSchemaForClientAsync(clientId, ct); + if (schemaCtx == null) + yield break; + yield return (schemaCtx.ClientId, schemaCtx.SchemaName); + } + + // Runs the overdue-lock sweep across every client with a lock policy immediately, rather + // than waiting for the next 6-hour tick. Uses the exact same OverdueLockRunner the + // hosted service itself calls. No run-history endpoint yet -- OverdueLockJob doesn't + // record to IJobRunHistory today; that's a separate follow-up if it's ever needed. + [HttpPost("overdue-lock/trigger")] + public async Task>> TriggerOverdueLockNow(CancellationToken ct) + { + var now = DateTime.UtcNow; + var clients = await _overdueLockRepository.GetClientsWithOverdueLockPolicyAsync(ct); + + var failures = new List(); + foreach (var client in clients) + { + try + { + await _overdueLockRunner.RunForClientAsync(client, now, _iterator, ct); + } + catch (Exception ex) + { + failures.Add($"{client.ClientId}: {ex.Message}"); + } + } + + return Response(new + { + clientsChecked = clients.Count, + failed = failures.Count, + failures + }); + } + } +} diff --git a/Controllers/AdminBillingController.cs b/Controllers/AdminBillingController.cs new file mode 100644 index 0000000..7ceb3fa --- /dev/null +++ b/Controllers/AdminBillingController.cs @@ -0,0 +1,94 @@ +using Fitness_core_BE.DTOs.PlatformBilling; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models.IOs; +using Microsoft.AspNetCore.Mvc; +using static Fitness_core_BE.Helpers.JwtMiddleware; +using static Fitness_core_BE.Helpers.ApiResponseHelper; + +namespace Fitness_core_BE.Controllers +{ + // Platform billing (HexaFitness -> gym): pricing configuration and invoicing. + // The gym-owner side of this same concern lives in ClientInvoiceController. + [ApiController] + [Route("api/admin")] + [CustomMiddleware] + [TenantResolution] + public class AdminBillingController : ControllerBase + { + private readonly IPlatformBillingService _platformBilling; + + public AdminBillingController(IPlatformBillingService platformBilling) + { + _platformBilling = platformBilling; + } + + [HttpGet("pricing-plans")] + public async Task>>> ListPricingPlans() + { + return Response(await _platformBilling.GetPricingPlansAsync(HttpContext.RequestAborted)); + } + + [HttpPut("pricing-plans")] + public async Task>> UpsertPricingPlan([FromBody] UpsertPricingPlanRequest request) + { + return Response(await _platformBilling.UpsertPricingPlanAsync(request, HttpContext.RequestAborted)); + } + + [HttpGet("device-pricing")] + public async Task>> GetDevicePricing() + { + return Response(await _platformBilling.GetDevicePricingAsync(HttpContext.RequestAborted)); + } + + [HttpPut("device-pricing")] + public async Task>> UpdateDevicePricing([FromBody] DevicePricingDto request) + { + return Response(await _platformBilling.UpdateDevicePricingAsync(request, HttpContext.RequestAborted)); + } + + [HttpPost("clients/{id:guid}/pricing-plan")] + public async Task>> AssignPricingPlan(Guid id, [FromBody] AssignPricingPlanRequest request) + { + var invoice = await _platformBilling.AssignPricingPlanAsync(id, request, HttpContext.RequestAborted); + return Response(new { assigned = true, clientInvoiceId = invoice.ClientInvoiceId, amount = invoice.Amount }); + } + + [HttpGet("clients/{id:guid}/platform-fee")] + public async Task>> GetPlatformFee(Guid id) + { + return Response(await _platformBilling.ComputeMonthlyFeeAsync(id, HttpContext.RequestAborted)); + } + + [HttpGet("invoices/pending")] + public async Task>>> ListPendingInvoices() + { + return Response(await _platformBilling.GetPendingInvoicesAsync(HttpContext.RequestAborted)); + } + + [HttpGet("invoices/completed")] + public async Task>>> ListCompletedInvoices() + { + return Response(await _platformBilling.GetCompletedInvoicesAsync(HttpContext.RequestAborted)); + } + + [HttpPost("invoices/{id:guid}/payments")] + public async Task>> RecordInvoicePayment(Guid id, [FromBody] RecordClientInvoicePaymentRequest request) + { + return Response(await _platformBilling.RecordClientInvoicePaymentAsync(id, request.Amount, HttpContext.RequestAborted)); + } + + [HttpPost("invoices/{id:guid}/send-payment-link")] + public async Task>> SendInvoicePaymentLink(Guid id) + { + return Response(await _platformBilling.SendClientPaymentLinkAsync(id, HttpContext.RequestAborted)); + } + + [HttpPost("billing/run-monthly")] + public async Task>> RunMonthlyBilling() + { + var invoices = await _platformBilling.RunMonthlyBillingForAllClientsAsync(HttpContext.RequestAborted); + return Response(new { generated = invoices.Count }); + } + } +} diff --git a/Controllers/AdminController.cs b/Controllers/AdminController.cs new file mode 100644 index 0000000..4540dba --- /dev/null +++ b/Controllers/AdminController.cs @@ -0,0 +1,99 @@ +using Fitness_core_BE.DTOs.PaymentsEnablement; +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models.IOs; +using Microsoft.AspNetCore.Mvc; +using static Fitness_core_BE.Helpers.JwtMiddleware; +using static Fitness_core_BE.Helpers.ApiResponseHelper; + +namespace Fitness_core_BE.Controllers +{ + [ApiController] + [Route("api/admin")] + [CustomMiddleware] + [TenantResolution] + public class AdminController : ControllerBase + { + private readonly IPayoutKycReviewService _kycReview; + private readonly IAdminClientDirectoryService _clientDirectory; + private readonly UserRoleHelper _userRoleHelper; + private readonly IAdminSuggestionService _suggestions; + + public AdminController( + IPayoutKycReviewService kycReview, + IAdminClientDirectoryService clientDirectory, + UserRoleHelper userRoleHelper, + IAdminSuggestionService suggestions) + { + _kycReview = kycReview; + _clientDirectory = clientDirectory; + _userRoleHelper = userRoleHelper; + _suggestions = suggestions; + } + + [HttpGet("payout-kyc")] + public async Task>>> ListKyc([FromQuery] string? status) + { + return Response(await _kycReview.ListAsync(status)); + } + + [HttpGet("payout-kyc/{clientId:guid}")] + public async Task>> GetKyc(Guid clientId) + { + return Response(await _kycReview.GetByClientIdAsync(clientId)); + } + + [HttpPatch("payout-kyc/{clientId:guid}/status")] + public async Task>> ReviewKyc(Guid clientId, [FromBody] ReviewPayoutKycRequest request) + { + return Response(await _kycReview.ReviewAsync(clientId, request, HttpContext)); + } + + [HttpGet("clients")] + public async Task>>> ListClients([FromQuery] ClientDirectoryQuery query) + { + return Response(await _clientDirectory.ListAsync(query)); + } + + [HttpGet("clients/{id:guid}")] + public async Task>> GetClient(Guid id) + { + return Response(await _clientDirectory.GetByIdAsync(id)); + } + + [HttpPost("clients/{id:guid}/pairing-code")] + public async Task>> GeneratePairingCode(Guid id) + { + return Response(await _clientDirectory.GeneratePairingCodeAsync(id)); + } + + [HttpPost("clients/{id:guid}/resend-welcome-email")] + public async Task>> ResendWelcomeEmail(Guid id) + { + await _clientDirectory.ResendWelcomeEmailAsync(id, HttpContext); + return Response(new { sent = true }); + } + + [HttpGet("clients/{id:guid}/access-devices")] + public async Task>> GetAccessDevices(Guid id) + { + return Response(await _clientDirectory.GetDevicesAsync(id)); + } + + // Platform plans are global templates (no FKClientId). Mobile customers see them + // in Suggestions in addition to templates created by their own gym. + [HttpGet("suggestions")] + public async Task>> ListSuggestions() + => Response(await _suggestions.ListAsync()); + + [HttpPost("suggestions/workouts")] + public async Task>> CreateWorkoutSuggestion([FromBody] AdminWorkoutSuggestionRequest request) + => Response(await _suggestions.CreateWorkoutAsync(request, HttpContext)); + + [HttpPost("suggestions/meals")] + public async Task>> CreateMealSuggestion([FromBody] AdminMealSuggestionRequest request) + => Response(await _suggestions.CreateMealAsync(request, HttpContext)); + } + +} diff --git a/Controllers/AdminPaymentGatewayController.cs b/Controllers/AdminPaymentGatewayController.cs new file mode 100644 index 0000000..2200a61 --- /dev/null +++ b/Controllers/AdminPaymentGatewayController.cs @@ -0,0 +1,55 @@ +using Fitness_core_BE.DTOs.PaymentGateways; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models.IOs; +using Microsoft.AspNetCore.Mvc; +using static Fitness_core_BE.Helpers.JwtMiddleware; +using static Fitness_core_BE.Helpers.ApiResponseHelper; + +namespace Fitness_core_BE.Controllers +{ + // Payment gateway catalog (proxied to Payment-Service; see PaymentServiceClient). + // This is platform-admin's side of gateway registration — the gym-owner side + // (payout track/KYC/wallet enablement) lives in PaymentsEnablementController. + [ApiController] + [Route("api/admin")] + [CustomMiddleware] + [TenantResolution] + public class AdminPaymentGatewayController : ControllerBase + { + private readonly IPaymentServiceClient _paymentServiceClient; + + public AdminPaymentGatewayController(IPaymentServiceClient paymentServiceClient) + { + _paymentServiceClient = paymentServiceClient; + } + + [HttpGet("payment-gateways")] + public async Task>>> ListPaymentGateways() + { + return Response(await _paymentServiceClient.ListGatewaysAsync(HttpContext.RequestAborted)); + } + + [HttpPost("payment-gateways")] + public async Task>> UpsertPaymentGateway([FromBody] UpsertGatewayDefinitionRequest request) + { + return Response(await _paymentServiceClient.UpsertGatewayAsync(request, HttpContext.RequestAborted)); + } + + [HttpPut("payment-gateways/{id:guid}/enabled")] + public async Task>> SetPaymentGatewayEnabled(Guid id, [FromBody] SetGatewayEnabledRequest request) + { + return Response(await _paymentServiceClient.SetGatewayEnabledAsync(id, request.IsEnabled, HttpContext.RequestAborted)); + } + + [HttpPut("payment-gateways/platforms/{platformId:guid}/credentials/{gatewayName}")] + public async Task>> UpsertPlatformGatewayCredential( + Guid platformId, string gatewayName, [FromBody] UpsertPlatformGatewayCredentialRequest request) + { + request.PlatformId = platformId; + request.GatewayName = gatewayName; + await _paymentServiceClient.UpsertPlatformCredentialAsync(request, HttpContext.RequestAborted); + return Response(new { saved = true }); + } + } +} diff --git a/Controllers/AgentController.cs b/Controllers/AgentController.cs new file mode 100644 index 0000000..0f56726 --- /dev/null +++ b/Controllers/AgentController.cs @@ -0,0 +1,153 @@ +using Fitness_core_BE.Auth; +using Fitness_core_BE.DTOs.Agent; +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models.IOs; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Mvc; + +namespace Fitness_core_BE.Controllers +{ + /// + /// Endpoints consumed by the on-prem desktop agent. The agent talks to the Hikvision + /// device on the gym LAN and to us over outbound HTTPS using its X-Api-Key. + /// All endpoints except enrollment require the "AgentApiKey" policy; the agent's client + /// is always resolved server-side from the authenticated key, never from the request body. + /// + [Route("api/agent")] + [ApiController] + [Produces("application/json")] + [EnableCors("AllowAll")] + public class AgentController : ControllerBase + { + private readonly IAgentService _agentService; + private readonly ITimeBankCardService _timeBankCardService; + private readonly ILogger _logger; + + public AgentController(IAgentService agentService, ITimeBankCardService timeBankCardService, ILogger logger) + { + _agentService = agentService; + _timeBankCardService = timeBankCardService; + _logger = logger; + } + + // Bootstrap: no api-key yet. Exchanges a pairing code for a new agent + api key. + [HttpPost("enroll")] + [AllowAnonymous] + public Task Enroll([FromBody] EnrollRequest request) + => Execute(() => _agentService.EnrollAsync(request)); + + [HttpPost("devices")] + [Authorize(Policy = AgentApiKeyDefaults.PolicyName)] + public Task RegisterDevice([FromBody] RegisterDeviceRequest request) + => Execute(() => _agentService.RegisterDeviceAsync(GetClientId(), GetAgentId(), request)); + + [HttpPost("device-inventory")] + [Authorize(Policy = AgentApiKeyDefaults.PolicyName)] + public Task ReconcileDeviceInventory([FromBody] DeviceInventoryRequest request) + => Execute(() => _agentService.ReconcileDeviceInventoryAsync( + GetClientId(), GetAgentId(), request)); + + [HttpPost("attendance")] + [Authorize(Policy = AgentApiKeyDefaults.PolicyName)] + public Task Attendance([FromBody] AgentAttendanceRequest request) + { + _logger.LogInformation( + "Attendance endpoint hit: device {DeviceId}, {EventCount} events in body.", + request?.DeviceId, request?.Events?.Count ?? 0); + return Execute(() => _agentService.IngestAttendanceAsync( + GetClientId(), GetAgentId(), request)); + } + + [HttpGet("commands")] + [Authorize(Policy = AgentApiKeyDefaults.PolicyName)] + public Task GetCommands() + => Execute(() => _agentService.GetPendingCommandsAsync(GetClientId(), GetAgentId())); + + [HttpPost("commands/{id}/result")] + [Authorize(Policy = AgentApiKeyDefaults.PolicyName)] + public Task SubmitCommandResult(Guid id, [FromBody] CommandResultRequest request) + => Execute(() => _agentService.SubmitCommandResultAsync( + GetClientId(), GetAgentId(), id, request)); + + // Called by an RFID-reader-equipped agent station on a card swipe -- same + // "agent resolves the club from its own API key" pattern as attendance/commands, so a + // reader never needs staff credentials of its own. facilityId identifies which area the + // reader is stationed at. First swipe on a card taps it in (starts the clock); the next + // swipe taps it out and bills the elapsed time -- see TimeBankCardService.Swipe. + [HttpPost("time-bank-cards/redeem")] + [Authorize(Policy = AgentApiKeyDefaults.PolicyName)] + public Task RedeemTimeBankCard([FromBody] AgentSwipeTimeBankCardRequest request) + => Execute(() => _timeBankCardService.Swipe(new + { + rfidTagId = request.RfidTagId, + facilityId = request.FacilityId + })); + + [HttpPost("heartbeat")] + [Authorize(Policy = AgentApiKeyDefaults.PolicyName)] + public async Task Heartbeat() + { + // Accept an empty body from older agents during a rolling deployment. + var request = Request.HasJsonContentType() + ? await Request.ReadFromJsonAsync() + : null; + return await Execute(() => _agentService.HeartbeatAsync( + GetClientId(), GetAgentId(), request ?? new AgentHeartbeatRequest())); + } + + // ---- helpers ---- + + private Guid GetClientId() + { + var value = User.FindFirst(AgentApiKeyDefaults.ClientIdClaim)?.Value; + if (string.IsNullOrWhiteSpace(value) || !Guid.TryParse(value, out var clientId)) + throw new UnauthorizedException("Agent is not authenticated."); + return clientId; + } + + private Guid GetAgentId() + { + var value = User.FindFirst(AgentApiKeyDefaults.AgentIdClaim)?.Value; + if (string.IsNullOrWhiteSpace(value) || !Guid.TryParse(value, out var agentId)) + throw new UnauthorizedException("Agent is not authenticated."); + return agentId; + } + + // Mirrors the function-handler exception->ApiResponse mapping used by admin flows. + private async Task Execute(Func> action) + { + try + { + var data = await action(); + return StatusCode(200, new ApiResponse + { + StatusCode = 200, + Message = "success", + Data = data + }); + } + catch (UnauthorizedException ex) + { + return StatusCode(401, new ApiResponse { StatusCode = 401, Message = ex.Message, Data = null! }); + } + catch (NotFoundException ex) + { + return StatusCode(404, new ApiResponse { StatusCode = 404, Message = ex.Message, Data = null! }); + } + catch (BadRequestException ex) + { + return StatusCode(400, new ApiResponse { StatusCode = 400, Message = ex.Message, Data = null! }); + } + catch (ForbiddenException ex) + { + return StatusCode(403, new ApiResponse { StatusCode = 403, Message = ex.Message, Data = null! }); + } + catch (Exception ex) + { + return StatusCode(500, new ApiResponse { StatusCode = 500, Message = $"An error occurred: {ex.Message}", Data = null! }); + } + } + } +} diff --git a/Controllers/BookingController.cs b/Controllers/BookingController.cs new file mode 100644 index 0000000..9e0de01 --- /dev/null +++ b/Controllers/BookingController.cs @@ -0,0 +1,103 @@ +using Fitness_core_BE.DTOs.Booking; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models.IOs; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using static Fitness_core_BE.Helpers.JwtMiddleware; +using static Fitness_core_BE.Helpers.ApiResponseHelper; + +namespace Fitness_core_BE.Controllers +{ + // Booking spans three audiences with very different auth needs, so routes are absolute + // per-action rather than grouped under one controller-level [Route] prefix: "api/app/..." + // for logged-in members (same convention as CustomerController), "api/public/..." for a + // non-member's anonymous guest booking, "api/client/..." for staff (check-in, cancel, + // session roster, manual payment recording). + [ApiController] + public class BookingController : ControllerBase + { + private readonly IBookingService _bookingService; + + public BookingController(IBookingService bookingService) + { + _bookingService = bookingService; + } + + // ---------- Member (logged-in) ---------- + + [CustomMiddleware] + [TenantResolution] + [HttpPost("api/app/bookings")] + public async Task>> CreateMemberBooking([FromBody] CreateMemberBookingRequest request) + => Response(await _bookingService.CreateMemberBooking(new { facilitySessionId = request.FacilitySessionId }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpGet("api/app/bookings/mine")] + public async Task>> GetMyBookings() + => Response(await _bookingService.GetMyBookings(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpPost("api/app/bookings/{id:guid}/cancel")] + public async Task>> CancelMyBooking(Guid id) + => Response(await _bookingService.CancelBooking(new { bookingId = id }, HttpContext)); + + // ---------- Guest (no account at all) ---------- + + [AllowAnonymous] + [HttpPost("api/public/bookings/guest")] + public async Task>> CreateGuestBooking([FromBody] CreateGuestBookingRequest request) + => Response(await _bookingService.CreateGuestBooking(new + { + facilitySessionId = request.FacilitySessionId, + guestName = request.GuestName, + guestPhone = request.GuestPhone + }, HttpContext)); + + // The actual "copy the link and share it" page -- BookingDto.ShareUrl points here. + // Plain HTML, not the JSON ApiResponse envelope, since this is meant to be opened + // directly in a browser (or a chat app's link preview), not consumed by a client SDK. + [AllowAnonymous] + [HttpGet("api/public/bookings/{id:guid}/view")] + public async Task ViewBooking(Guid id) + { + var booking = await _bookingService.GetBookingView(new { bookingId = id }) as BookingDto; + if (booking == null) + return Content(BookingViewHtml.NotFound(), "text/html"); + + return Content(BookingViewHtml.Render(booking), "text/html"); + } + + // ---------- Staff ---------- + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("bookings")] + [HttpGet("api/client/bookings/session/{facilitySessionId:guid}")] + public async Task>> GetSessionBookings(Guid facilitySessionId) + => Response(await _bookingService.GetSessionBookings(new { facilitySessionId }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("bookings")] + [HttpPost("api/client/bookings/check-in")] + public async Task>> CheckInBooking([FromBody] CheckInBookingRequest request) + => Response(await _bookingService.CheckInBooking(new { otpCode = request.OtpCode }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("bookings")] + [HttpPost("api/client/bookings/{id:guid}/mark-paid")] + public async Task>> MarkGuestBookingPaid(Guid id, [FromBody] MarkGuestBookingPaidRequest request) + => Response(await _bookingService.MarkGuestBookingPaid(new { bookingId = id, amountCharged = request.AmountCharged }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("bookings")] + [HttpPost("api/client/bookings/{id:guid}/cancel")] + public async Task>> CancelBooking(Guid id) + => Response(await _bookingService.CancelBooking(new { bookingId = id }, HttpContext)); + } +} diff --git a/Controllers/BootstrapController.cs b/Controllers/BootstrapController.cs new file mode 100644 index 0000000..20f62e6 --- /dev/null +++ b/Controllers/BootstrapController.cs @@ -0,0 +1,43 @@ +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models.IOs; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using static Fitness_core_BE.Helpers.ApiResponseHelper; + +namespace Fitness_core_BE.Controllers +{ + // One-time setup only -- see BootstrapService for why this exists instead of the + // multi-gym onboarding/activation flow. [AllowAnonymous] is safe here specifically because + // the service itself refuses to run a second time once a Client row exists; there is no + // window where this endpoint stays open as a standing unauthenticated write path. + [ApiController] + [Route("api/bootstrap")] + public class BootstrapController : ControllerBase + { + private readonly IBootstrapService _bootstrapService; + + public BootstrapController(IBootstrapService bootstrapService) + { + _bootstrapService = bootstrapService; + } + + public class BootstrapClubRequest + { + public Guid UserId { get; set; } + public string ClientName { get; set; } = string.Empty; + public string? ClientPhone { get; set; } + public string? ClientEmail { get; set; } + } + + [AllowAnonymous] + [HttpPost("club")] + public async Task>> BootstrapClub([FromBody] BootstrapClubRequest request) + => Response(await _bootstrapService.BootstrapClub(new + { + userId = request.UserId, + clientName = request.ClientName, + clientPhone = request.ClientPhone, + clientEmail = request.ClientEmail + })); + } +} diff --git a/Controllers/ClientController.cs b/Controllers/ClientController.cs new file mode 100644 index 0000000..c56942c --- /dev/null +++ b/Controllers/ClientController.cs @@ -0,0 +1,1222 @@ +using System.Text.Json; +using Fitness_core_BE.DTOs.AccessDevice; +using Fitness_core_BE.DTOs.CustomerPlan; +using Fitness_core_BE.DTOs.CustomerSubscription; +using Fitness_core_BE.DTOs.Ledger; +using Fitness_core_BE.DTOs.Mobile; +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models.IOs; +using Microsoft.AspNetCore.Mvc; +using static Fitness_core_BE.Helpers.JwtMiddleware; +using static Fitness_core_BE.Helpers.ApiResponseHelper; + +namespace Fitness_core_BE.Controllers +{ + [ApiController] + [Route("api/client")] + public class ClientController : ControllerBase + { + private readonly IClientService _clientService; + private readonly IStaffService _staffService; + private readonly IClientStatService _clientStatService; + private readonly IAttendanceService _attendanceService; + private readonly ICustomerPlanService _customerPlanService; + private readonly IWorkoutService _workoutService; + private readonly IMealService _mealService; + private readonly IFeedService _feedService; + private readonly ITicketService _ticketService; + private readonly ICustomerSubscriptionService _customerSubscriptionService; + private readonly UserRoleHelper _userRoleHelper; + private readonly IAccessDeviceService _accessDeviceService; + private readonly IAdminClientDirectoryService _clientDirectoryService; + private readonly IProfitLossService _profitLossService; + + public ClientController( + IClientService clientService, + IStaffService staffService, + IClientStatService clientStatService, + IAttendanceService attendanceService, + ICustomerPlanService customerPlanService, + IWorkoutService workoutService, + IMealService mealService, + IFeedService feedService, + ITicketService ticketService, + ICustomerSubscriptionService customerSubscriptionService, + UserRoleHelper userRoleHelper, + IAccessDeviceService accessDeviceService, + IAdminClientDirectoryService clientDirectoryService, + IProfitLossService profitLossService) + { + _clientService = clientService; + _staffService = staffService; + _clientStatService = clientStatService; + _attendanceService = attendanceService; + _customerPlanService = customerPlanService; + _workoutService = workoutService; + _mealService = mealService; + _feedService = feedService; + _ticketService = ticketService; + _customerSubscriptionService = customerSubscriptionService; + _userRoleHelper = userRoleHelper; + _accessDeviceService = accessDeviceService; + _clientDirectoryService = clientDirectoryService; + _profitLossService = profitLossService; + } + + // ---------- C. Own profile & business ---------- + + [CustomMiddleware] + [TenantResolution] + [HttpGet("me")] + public async Task>> GetMyProfile() + => Response(await _clientService.GetClientById(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpGet("packages")] + public async Task>> GetPackages() + => Response(await _clientService.GetPackageList(new { }, HttpContext)); + + // ---------- C. Own profile & business ---------- + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("settings")] + [HttpPut("gym-profile")] + public async Task>> UpdateGymProfile( + [FromBody] UpdateGymProfileRequest request) + => Response(await _clientService.UpdateGymProfile(request, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("settings")] + [HttpPost("gym-profile/logo")] + [RequestSizeLimit(6_000_000)] + [Consumes("multipart/form-data")] + public async Task>> UploadGymLogo([FromForm] IFormFile logo) + => Response(await _clientService.UploadGymLogo(logo, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("settings")] + [HttpGet("gym-profile/logo")] + public async Task>> GetGymLogo() + => Response(await _clientService.GetGymLogo(HttpContext)); + + // Gym-specific Terms & Conditions authoring — publishing deactivates the previous + // version (customers keep their acceptance record pointing at the version they saw). + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("settings")] + [HttpGet("gym-profile/terms")] + public async Task>> GetMyGymTerms() + => Response(await _clientService.GetMyGymTerms(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("settings")] + [HttpPut("gym-profile/terms")] + public async Task>> PublishGymTerms([FromBody] PublishGymTermsRequest request) + => Response(await _clientService.PublishGymTerms(new { content = request.Content }, HttpContext)); + + // Change the owner's own phone/email: send an OTP to the NEW value, then verify it. + // On verify the new value is written to the auth DB and mirrored onto the Client record. + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("settings")] + [HttpPost("me/contact/otp")] + public async Task>> SendContactOtp([FromBody] SendContactOtpRequest request) + => Response(await _clientService.SendContactOtp(new { identifier = request.Identifier }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("settings")] + [HttpPost("me/contact/verify")] + public async Task>> VerifyContactOtp([FromBody] VerifyContactOtpRequest request) + => Response(await _clientService.VerifyContactOtp(new + { + type = request.Type, + identifier = request.Identifier, + referenceNumber = request.ReferenceNumber, + otpCode = request.OtpCode + }, HttpContext)); + + // ---------- D. Customer management ---------- + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("members")] + [HttpGet("customers")] + public async Task>> GetCustomers() + => Response(await _clientService.GetCustomersByClientId(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("members")] + [HttpGet("customers/{customerId:guid}")] + public async Task>> GetCustomer(Guid customerId) + => Response(await _clientService.GetCustomerDetailsById(new { customerId }, HttpContext)); + + // Aggregated profile for the member "More Info" view (targets, progress, followed plans). + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("members")] + [HttpGet("customers/{customerId:guid}/full")] + public async Task>> GetCustomerFull(Guid customerId) + => Response(await _clientService.GetCustomerFullDetails(new { customerId }, HttpContext)); + + // Saved cards for this member (used to offer the auto-charge toggle on their subscription). + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("members")] + [HttpGet("customers/{customerId:guid}/cards")] + public async Task>> GetCustomerCards(Guid customerId) + => Response(await _clientService.GetCustomerCards(new { customerId }, HttpContext)); + + // Password-gated: returns medical details only when the owner's password is verified. + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("members")] + [HttpPost("customers/{customerId:guid}/medical")] + public async Task>> GetCustomerMedical(Guid customerId, [FromBody] MedicalUnlockRequest request) + => Response(await _clientService.GetCustomerMedical(new { customerId, password = request.Password }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("members")] + [HttpPost("customers")] + public async Task>> CreateCustomer([FromBody] CreateCustomerWithPlansRequest request) + => Response(await _clientService.CreateCustomerWithPlans(new + { + userName = request.UserName, + email = request.Email, + mobileNumber = request.MobileNumber, + fullname = request.Fullname, + gender = request.Gender, + birthday = request.Birthday, + nic = request.Nic, + workoutPlanIds = request.WorkoutPlanIds, + mealPlanIds = request.MealPlanIds, + customerPlanId = request.CustomerPlanId, + onboardDate = request.OnboardDate, + chargePayment = request.ChargePayment, + confirmLink = request.ConfirmLink, + discountType = request.DiscountType, + discountValue = request.DiscountValue + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("members")] + [HttpPut("customers/{customerId:guid}")] + public async Task>> UpdateCustomer(Guid customerId, [FromBody] UpdateCustomerByClientRequest request) + => Response(await _clientService.UpdateCustomerByClient(new + { + customerId, + userName = request.UserName, + email = request.Email, + phoneNumber = request.MobileNumber, + fullname = request.Fullname, + gender = request.Gender, + birthday = request.Birthday, + nic = request.Nic + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("members")] + [HttpDelete("customers/{customerId:guid}")] + public async Task>> DeleteCustomer(Guid customerId) + => Response(await _clientService.DeleteCustomerByClient(new { customerId }, HttpContext)); + + // "Parent" path: assigns a brand new plan/subscription to a customer who was created + // without one, making them the primary member. ("Child" path — joining an existing + // group subscription — is POST subscriptions/{subscriptionId}/members below.) + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("members")] + [HttpPost("customers/{customerId:guid}/subscription")] + public async Task>> AssignCustomerPlan(Guid customerId, [FromBody] AssignCustomerPlanRequest request) + => Response(await _clientService.AssignPlanToExistingCustomer(customerId, new + { + customerPlanId = request.CustomerPlanId, + expiryDate = request.ExpiryDate, + onboardDate = request.OnboardDate, + chargePayment = request.ChargePayment, + discountType = request.DiscountType, + discountValue = request.DiscountValue + }, HttpContext)); + + // Switches the customer onto a different Individual-scope plan — distinct from + // AssignCustomerPlan above, which is only for a customer with no active subscription + // yet. The customer's current plan may be Individual or Group; if they're the primary + // member of a Group subscription, the whole group is dropped to unassigned in the + // process, otherwise only their own membership row is replaced. + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("members")] + [HttpPut("customers/{customerId:guid}/subscription/plan")] + public async Task>> ChangeMembershipPlan(Guid customerId, [FromBody] ChangeMembershipPlanRequest request) + => Response(await _clientService.ChangeCustomerMembershipPlan(customerId, request.CustomerPlanId, HttpContext)); + + // Drops the customer's current plan back to unassigned without assigning a new one. If + // they're the primary member of a Group subscription, every member on it is unassigned. + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("members")] + [HttpDelete("customers/{customerId:guid}/subscription")] + public async Task>> UnassignMembership(Guid customerId) + => Response(await _clientService.UnassignCustomerMembership(customerId, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("members")] + [HttpGet("access-devices")] + public async Task>> GetAccessDevices() + { + var clientId = await _userRoleHelper.GetClientId( + DataExtractor.GetUserIdFromContext(HttpContext)); + return Response(await _clientDirectoryService.GetDevicesAsync(clientId)); + } + + // Fingerprints enrolled directly on the physical device (by staff) with no system + // Customer linked yet — surfaced so the client portal can offer them in a dropdown. + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("members")] + [HttpGet("customers/unlinked-fingerprints")] + public async Task>>> GetUnlinkedFingerprints() + { + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(HttpContext)); + var enrollments = await _accessDeviceService.GetUnlinkedFingerprintsAsync(clientId); + return Response(enrollments.Select(e => new UnlinkedFingerprintDto + { + Id = e.Id, + DeviceUserId = e.DeviceUserId, + DeviceEmployeeNo = string.IsNullOrWhiteSpace(e.DeviceEmployeeNo) + ? e.DeviceUserId.ToString() + : e.DeviceEmployeeNo, + DeviceReportedName = e.DeviceReportedName, + DeviceUserType = e.DeviceUserType, + FingerprintCount = e.FingerprintCount, + DeviceId = e.FKDeviceId, + DeviceName = e.AccessDevice?.DeviceName, + DiscoveredAt = e.DiscoveredAt + }).ToList()); + } + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("members")] + [HttpPost("customers/{customerId:guid}/link-fingerprint")] + public async Task>> LinkFingerprint(Guid customerId, [FromBody] LinkFingerprintRequest request) + { + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(HttpContext)); + var profile = await _accessDeviceService.LinkFingerprintAsync(clientId, customerId, request.UnlinkedEnrollmentId); + return Response(new + { + accessProfileId = profile.AccessProfileId, + accessNumber = profile.DeviceBindings + .OrderByDescending(b => b.LastSyncedAt) + .FirstOrDefault()?.DeviceEmployeeNo ?? $"HF{profile.AccessProfileId}", + name = profile.DisplayName + }); + } + + // Face photo enrolment. The JPEG rides the existing DeviceCommand queue to the + // on-prem agent, which pushes it into the Hikvision face library; the payload is + // purged once a terminal confirms, so the image is never stored here. + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("members")] + [HttpPost("customers/{customerId:guid}/face-photo")] + public async Task>> SetCustomerFacePhoto( + Guid customerId, [FromBody] SetFacePhotoRequest request) + { + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(HttpContext)); + return Response(await _accessDeviceService.SetCustomerFacePhotoAsync( + clientId, customerId, request.ImageBase64)); + } + + // Manual lock/unlock (req 7) — reuses the existing BlackListMember/WhiteListMember + // device-command pipeline (AccessDeviceService); also used automatically by OverdueLockJob. + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("members")] + [HttpPost("customers/{customerId:guid}/lock")] + public async Task>> LockCustomer(Guid customerId) + { + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(HttpContext)); + var status = await _accessDeviceService.SetCustomerAccessAsync( + clientId, customerId, enabled: false, reason: "Manual"); + return Response(status); + } + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("members")] + [HttpPost("customers/{customerId:guid}/unlock")] + public async Task>> UnlockCustomer(Guid customerId) + { + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(HttpContext)); + var status = await _accessDeviceService.SetCustomerAccessAsync( + clientId, customerId, enabled: true, reason: null); + return Response(status); + } + + // Auto-lock-after-non-payment settings. + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("members")] + [HttpGet("settings/lock-policy")] + public async Task>> GetLockPolicy() + => Response(await _clientService.GetLockPolicy(HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("members")] + [HttpPut("settings/lock-policy")] + public async Task>> UpdateLockPolicy([FromBody] UpdateLockPolicyRequest request) + => Response(await _clientService.UpdateLockPolicy(request.LockAfterDaysOverdue, HttpContext)); + + // Basic Profit & Loss / simple ledger. + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("reports")] + [HttpGet("reports/profit-loss")] + public async Task>> GetProfitLoss([FromQuery] DateTime from, [FromQuery] DateTime to) + => Response(await _profitLossService.GetProfitLossAsync(from, to, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("reports")] + [HttpGet("ledger-entries")] + public async Task>>> GetLedgerEntries([FromQuery] DateTime from, [FromQuery] DateTime to) + => Response(await _profitLossService.GetManualEntriesAsync(from, to, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("reports")] + [HttpPost("ledger-entries")] + public async Task>> AddLedgerEntry([FromBody] AddLedgerEntryRequest request) + => Response(await _profitLossService.AddManualEntryAsync(request, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("reports")] + [HttpDelete("ledger-entries/{id:guid}")] + public async Task>> DeleteLedgerEntry(Guid id) + { + await _profitLossService.DeleteManualEntryAsync(id, HttpContext); + return Response(new { deleted = true }); + } + + // ---------- E. Staff management ---------- + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("staff")] + [HttpGet("staff")] + public async Task>> GetStaff() + { + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(HttpContext)); + return Response(await _staffService.GetStaffByClientId(new { clientId }, HttpContext)); + } + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("staff")] + [HttpGet("staff/{staffId:guid}")] + public async Task>> GetStaffMember(Guid staffId) + => Response(await _staffService.GetStaffById(new { staffId }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("staff")] + [HttpPost("staff")] + public async Task>> CreateStaff([FromBody] CreateStaffRequest request) + => Response(await _staffService.CreateStaff(new + { + userName = request.UserName, + email = request.Email, + phoneNumber = request.PhoneNumber, + staffPosition = request.StaffPosition, + department = request.Department, + hireDate = request.HireDate, + gymRoleId = request.GymRoleId + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("staff")] + [HttpPut("staff/{staffId:guid}")] + public async Task>> UpdateStaff(Guid staffId, [FromBody] UpdateStaffRequest request) + => Response(await _staffService.UpdateStaff(new + { + staffId, + userName = request.UserName, + email = request.Email, + phoneNumber = request.PhoneNumber, + staffPosition = request.StaffPosition, + department = request.Department + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("staff")] + [HttpPost("staff/{staffId:guid}/link-fingerprint")] + public async Task>> LinkStaffFingerprint( + Guid staffId, [FromBody] LinkFingerprintRequest request) + { + var clientId = await _userRoleHelper.GetClientId( + DataExtractor.GetUserIdFromContext(HttpContext)); + var profile = await _accessDeviceService.LinkStaffFingerprintAsync( + clientId, staffId, request.UnlinkedEnrollmentId); + return Response(new + { + accessProfileId = profile.AccessProfileId, + accessNumber = profile.DeviceBindings + .OrderByDescending(b => b.LastSyncedAt) + .FirstOrDefault()?.DeviceEmployeeNo ?? $"HF{profile.AccessProfileId}", + name = profile.DisplayName + }); + } + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("staff")] + [HttpPost("staff/{staffId:guid}/lock")] + public async Task>> LockStaff(Guid staffId) + { + var clientId = await _userRoleHelper.GetClientId( + DataExtractor.GetUserIdFromContext(HttpContext)); + return Response(await _accessDeviceService.SetStaffAccessAsync( + clientId, staffId, enabled: false, reason: "Manual")); + } + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("staff")] + [HttpPost("staff/{staffId:guid}/unlock")] + public async Task>> UnlockStaff(Guid staffId) + { + var clientId = await _userRoleHelper.GetClientId( + DataExtractor.GetUserIdFromContext(HttpContext)); + return Response(await _accessDeviceService.SetStaffAccessAsync( + clientId, staffId, enabled: true, reason: null)); + } + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("staff")] + [HttpDelete("staff/{staffId:guid}")] + public async Task>> DeleteStaff(Guid staffId, [FromQuery] bool softDelete = true) + => Response(await _staffService.DeleteStaff(new { staffId, hardDelete = !softDelete }, HttpContext)); + + // Basic HRM/payroll: pay rate + payment history per staff member. + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("staff")] + [HttpGet("staff/{staffId:guid}/pay-rate")] + public async Task>> GetStaffPayRate(Guid staffId) + => Response(await _staffService.GetPayRate(staffId, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("staff")] + [HttpPut("staff/{staffId:guid}/pay-rate")] + public async Task>> UpsertStaffPayRate(Guid staffId, [FromBody] Fitness_core_BE.DTOs.Staff.UpsertStaffPayRateRequest request) + => Response(await _staffService.UpsertPayRate(staffId, request.RateType, request.Amount, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("staff")] + [HttpGet("staff/{staffId:guid}/payments")] + public async Task>> GetStaffPayments(Guid staffId) + => Response(await _staffService.GetPayments(staffId, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("staff")] + [HttpPost("staff/{staffId:guid}/payments")] + public async Task>> RecordStaffPayment(Guid staffId, [FromBody] Fitness_core_BE.DTOs.Staff.RecordStaffPaymentRequest request) + => Response(await _staffService.RecordPayment(staffId, request.Amount, request.Note, HttpContext)); + + // ---------- G. Dashboard stats ---------- + + [CustomMiddleware] + [TenantResolution] + [HttpGet("stats/summary")] + public async Task>> GetStatSummary([FromQuery] DateTime? from, [FromQuery] DateTime? to) + => Response(await _clientStatService.GetClientStatSummary(new { }, HttpContext, from, to)); + + [CustomMiddleware] + [TenantResolution] + [HttpGet("stats/monthly-revenue")] + public async Task>> GetMonthlyRevenue([FromQuery] DateTime? from, [FromQuery] DateTime? to) + => Response(await _clientStatService.GetMonthlyRevenueVariation(new { }, HttpContext, from, to)); + + [CustomMiddleware] + [TenantResolution] + [HttpGet("stats/recent-payments")] + public async Task>> GetRecentPayments() + => Response(await _clientStatService.GetLastFivePayments(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpGet("stats/recent-attendance")] + public async Task>> GetRecentAttendance() + => Response(await _clientStatService.GetLastFiveAttendances(new { }, HttpContext)); + + // ---------- I. Attendance ---------- + // The service resolves the clientId from the JWT in HttpContext. + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("attendance")] + [HttpGet("attendance")] + public async Task>> GetAllAttendance() + => Response(await _attendanceService.GetAllAttendanceRecords(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("attendance")] + [HttpGet("attendance/today")] + public async Task>> GetTodayAttendance() + => Response(await _attendanceService.GetTodayAttendance(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("attendance")] + [HttpGet("attendance/counts")] + public async Task>> GetAttendanceCounts( + [FromQuery] string? fromDate, [FromQuery] string? toDate) + => Response(await _attendanceService.GetAttendanceCounts(new { fromDate, toDate }, HttpContext)); + + // ---------- J. Membership plans (CustomerPlan) ---------- + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("memberships")] + [HttpGet("plans")] + public async Task>> GetMembershipPlans([FromQuery] bool includeInactive = false) + => Response(await _customerPlanService.GetAllCustomerPlans(new { includeInactive }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("memberships")] + [HttpGet("plans/{id:guid}")] + public async Task>> GetMembershipPlan(Guid id) + => Response(await _customerPlanService.GetCustomerPlanById(new { customerPlanId = id }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("memberships")] + [HttpPost("plans")] + public async Task>> CreateMembershipPlan([FromBody] CreateCustomerPlanRequest request) + { + // Omit-if-null: the service reads keys with GetString()/GetDecimal(), + // which throw on explicit JSON nulls. + var payload = new Dictionary + { + ["planName"] = request.PlanName, + ["planPrice"] = request.PlanPrice, + }; + if (request.PlanDescription != null) payload["planDescription"] = request.PlanDescription; + if (request.DurationInDays.HasValue) payload["durationInDays"] = request.DurationInDays.Value; + if (request.PlanScope != null) payload["planScope"] = request.PlanScope; + if (request.MemberLimit.HasValue) payload["memberLimit"] = request.MemberLimit.Value; + if (request.DurationMode != null) payload["durationMode"] = request.DurationMode; + if (request.ValidFrom.HasValue) payload["validFrom"] = request.ValidFrom.Value.ToString("o"); + if (request.ValidTo.HasValue) payload["validTo"] = request.ValidTo.Value.ToString("o"); + if (request.AllowSplitPayments.HasValue) payload["allowSplitPayments"] = request.AllowSplitPayments.Value; + if (request.InstallmentCount.HasValue) payload["installmentCount"] = request.InstallmentCount.Value; + if (request.HasOnboardingFee.HasValue) payload["hasOnboardingFee"] = request.HasOnboardingFee.Value; + if (request.OnboardingFeeAmount.HasValue) payload["onboardingFeeAmount"] = request.OnboardingFeeAmount.Value; + if (request.SessionsPerCycle.HasValue) payload["sessionsPerCycle"] = request.SessionsPerCycle.Value; + if (request.SessionRolloverPolicy != null) payload["sessionRolloverPolicy"] = request.SessionRolloverPolicy; + if (request.RolloverGraceDays.HasValue) payload["rolloverGraceDays"] = request.RolloverGraceDays.Value; + if (request.BillingTiming != null) payload["billingTiming"] = request.BillingTiming; + if (request.PlaceIds != null) payload["placeIds"] = request.PlaceIds; + + return Response(await _customerPlanService.CreateCustomerPlan(payload, HttpContext)); + } + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("memberships")] + [HttpPut("plans/{id:guid}")] + public async Task>> UpdateMembershipPlan(Guid id, [FromBody] UpdateCustomerPlanRequest request) + { + var payload = new Dictionary { ["customerPlanId"] = id }; + if (request.PlanName != null) payload["planName"] = request.PlanName; + if (request.PlanDescription != null) payload["planDescription"] = request.PlanDescription; + if (request.PlanPrice.HasValue) payload["planPrice"] = request.PlanPrice.Value; + if (request.DurationInDays.HasValue) payload["durationInDays"] = request.DurationInDays.Value; + if (request.PlanScope != null) payload["planScope"] = request.PlanScope; + if (request.MemberLimit.HasValue) payload["memberLimit"] = request.MemberLimit.Value; + if (request.DurationMode != null) payload["durationMode"] = request.DurationMode; + if (request.ValidFrom.HasValue) payload["validFrom"] = request.ValidFrom.Value.ToString("o"); + if (request.ValidTo.HasValue) payload["validTo"] = request.ValidTo.Value.ToString("o"); + if (request.AllowSplitPayments.HasValue) payload["allowSplitPayments"] = request.AllowSplitPayments.Value; + if (request.InstallmentCount.HasValue) payload["installmentCount"] = request.InstallmentCount.Value; + if (request.HasOnboardingFee.HasValue) payload["hasOnboardingFee"] = request.HasOnboardingFee.Value; + if (request.OnboardingFeeAmount.HasValue) payload["onboardingFeeAmount"] = request.OnboardingFeeAmount.Value; + if (request.SessionsPerCycle.HasValue) payload["sessionsPerCycle"] = request.SessionsPerCycle.Value; + if (request.SessionRolloverPolicy != null) payload["sessionRolloverPolicy"] = request.SessionRolloverPolicy; + if (request.RolloverGraceDays.HasValue) payload["rolloverGraceDays"] = request.RolloverGraceDays.Value; + if (request.BillingTiming != null) payload["billingTiming"] = request.BillingTiming; + if (request.IsActive.HasValue) payload["isActive"] = request.IsActive.Value; + if (request.PlaceIds != null) payload["placeIds"] = request.PlaceIds; + + return Response(await _customerPlanService.UpdateCustomerPlan(payload, HttpContext)); + } + + // Status-only toggle -- the counterpart to the soft delete below, and the only way + // to bring a deactivated plan back. Kept off the PUT so reactivating never has to + // resend (or re-satisfy validation on) the plan's whole configuration. + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("memberships")] + [HttpPatch("plans/{id:guid}/status")] + public async Task>> SetMembershipPlanStatus( + Guid id, [FromBody] SetCustomerPlanStatusRequest request) + => Response(await _customerPlanService.SetCustomerPlanStatus(new + { + customerPlanId = id, + isActive = request.IsActive + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("memberships")] + [HttpDelete("plans/{id:guid}")] + public async Task>> DeleteMembershipPlan(Guid id, [FromQuery] bool hard = false) + => Response(await _customerPlanService.DeleteCustomerPlan(new + { + customerPlanId = id, + hardDelete = hard + }, HttpContext)); + + // ---------- K. Workout plans ---------- + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("workout-plans")] + [HttpGet("workout-plans")] + public async Task>> GetWorkoutPlans() + => Response(await _workoutService.GetWorkoutPlanList(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("workout-plans")] + [HttpGet("workout-plans/{id:guid}")] + public async Task>> GetWorkoutPlan(Guid id) + => Response(await _workoutService.GetWorkoutPlanById(new { workoutPlanId = id }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("workout-plans")] + [HttpPost("workout-plans")] + public async Task>> CreateWorkoutPlan([FromBody] WorkoutPlanRequest request) + => Response(await _workoutService.CreateWorkoutPlan(BuildWorkoutPlanPayload(request), HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("workout-plans")] + [HttpPut("workout-plans/{id:guid}")] + public async Task>> UpdateWorkoutPlan(Guid id, [FromBody] WorkoutPlanRequest request) + => Response(await _workoutService.UpdateWorkoutPlan(BuildWorkoutPlanPayload(request, id), HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("workout-plans")] + [HttpDelete("workout-plans/{id:guid}")] + public async Task>> DeleteWorkoutPlan(Guid id) + => Response(await _workoutService.DeleteWorkoutPlan(new { workoutPlanId = id }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("workout-plans")] + [HttpGet("workout-plans/{id:guid}/items")] + public async Task>> GetWorkoutItems(Guid id) + => Response(await _workoutService.GetWorkoutItemsByWorkoutPlanId(new { workoutPlanId = id }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("workout-plans")] + [HttpPost("workout-plans/{id:guid}/items")] + public async Task>> CreateWorkoutItem(Guid id, [FromBody] WorkoutItemRequest request) + { + var payload = new Dictionary + { + ["workoutPlanId"] = id, + ["exerciseDay"] = request.ExerciseDay, + }; + if (request.Description != null) payload["description"] = request.Description; + if (request.Exercises.HasValue) payload["exercises"] = request.Exercises.Value; + if (request.HowToDo != null) payload["howToDo"] = request.HowToDo; + if (request.DoURL != null) payload["doURL"] = request.DoURL; + + return Response(await _workoutService.CreateWorkoutItem(payload, HttpContext)); + } + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("workout-plans")] + [HttpGet("workout-plans/categories")] + public async Task>> GetWorkoutCategories() + => Response(await _workoutService.GetWorkoutCategoryList(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("workout-plans")] + [HttpPost("workout-plans/categories")] + public async Task>> CreateWorkoutCategory([FromBody] PlanCategoryRequest request) + => Response(await _workoutService.CreateWorkoutCategory(new + { + name = request.Name, + description = request.Description + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("workout-plans")] + [HttpPost("workout-plans/{id:guid}/assignments")] + public async Task>> AssignWorkout(Guid id, [FromBody] PlanAssignRequest request) + => Response(await _workoutService.AssignWorkoutToCustomer(new + { + workoutPlanId = id, + customerId = request.CustomerId, + notes = request.Notes + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("workout-plans")] + [HttpDelete("workout-plans/assignments/{customerWorkoutId:guid}")] + public async Task>> UnassignWorkout(Guid customerWorkoutId) + => Response(await _workoutService.UnassignWorkoutFromCustomer(new { customerWorkoutId }, HttpContext)); + + // ---------- L. Meal plans ---------- + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("meal-plans")] + [HttpGet("meal-plans")] + public async Task>> GetMealPlans() + => Response(await _mealService.GetMealPlanList(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("meal-plans")] + [HttpGet("meal-plans/{id:guid}")] + public async Task>> GetMealPlan(Guid id) + => Response(await _mealService.GetMealPlanById(new { mealPlanId = id }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("meal-plans")] + [HttpPost("meal-plans")] + public async Task>> CreateMealPlan([FromBody] MealPlanRequest request) + => Response(await _mealService.CreateMealPlan(BuildMealPlanPayload(request), HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("meal-plans")] + [HttpPut("meal-plans/{id:guid}")] + public async Task>> UpdateMealPlan(Guid id, [FromBody] MealPlanRequest request) + => Response(await _mealService.UpdateMealPlan(BuildMealPlanPayload(request, id), HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("meal-plans")] + [HttpDelete("meal-plans/{id:guid}")] + public async Task>> DeleteMealPlan(Guid id) + => Response(await _mealService.DeleteMealPlan(new { mealPlanId = id }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("meal-plans")] + [HttpGet("meal-plans/{id:guid}/items")] + public async Task>> GetMealItems(Guid id) + => Response(await _mealService.GetMealItemsByMealPlanId(new { mealPlanId = id }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("meal-plans")] + [HttpPost("meal-plans/{id:guid}/items")] + public async Task>> CreateMealItem(Guid id, [FromBody] MealItemRequest request) + { + var payload = new Dictionary + { + ["mealPlanId"] = id, + ["name"] = request.Name, + }; + if (request.Description != null) payload["description"] = request.Description; + if (request.Calories != null) payload["calories"] = request.Calories; + if (request.ServingSize != null) payload["servingSize"] = request.ServingSize; + if (request.PreparationNotes != null) payload["preparationNotes"] = request.PreparationNotes; + + return Response(await _mealService.CreateMealItem(payload, HttpContext)); + } + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("meal-plans")] + [HttpGet("meal-plans/categories")] + public async Task>> GetMealCategories() + => Response(await _mealService.GetMealCategoryList(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("meal-plans")] + [HttpPost("meal-plans/categories")] + public async Task>> CreateMealCategory([FromBody] PlanCategoryRequest request) + => Response(await _mealService.CreateMealCategory(new + { + name = request.Name, + description = request.Description + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("meal-plans")] + [HttpPost("meal-plans/{id:guid}/assignments")] + public async Task>> AssignMeal(Guid id, [FromBody] PlanAssignRequest request) + => Response(await _mealService.AssignMealToCustomer(new + { + mealPlanId = id, + customerId = request.CustomerId, + notes = request.Notes + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("meal-plans")] + [HttpDelete("meal-plans/assignments/{customerMealId:guid}")] + public async Task>> UnassignMeal(Guid customerMealId) + => Response(await _mealService.UnassignMealFromCustomer(new { customerMealId }, HttpContext)); + + // ---------- M. Feed ---------- + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("feed")] + [HttpGet("feed")] + public async Task>> GetFeed() + => Response(await _feedService.ShowFeedByClient(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("feed")] + [HttpPost("feed")] + [RequestSizeLimit(20_000_000)] + public async Task>> CreateFeed([FromBody] CreateFeedRequest request) + { + var payload = new Dictionary + { + ["caption"] = request.Caption, + ["description"] = request.Description, + }; + if (request.Document.HasValue && request.Document.Value.ValueKind == JsonValueKind.Array) + payload["document"] = request.Document.Value; + + return Response(await _feedService.CreateFeed(payload, HttpContext)); + } + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("feed")] + [HttpDelete("feed/{postId:guid}")] + public async Task>> DeleteFeed(Guid postId) + => Response(await _feedService.DeleteFeedByPostId(new { postId }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("feed")] + [HttpPost("feed/{postId:guid}/like")] + public async Task>> LikeFeed(Guid postId) + => Response(await _feedService.LikeAsync(new { postId }, HttpContext)); + + // FeedService.UnlikeAsync reads userId from the payload (unlike LikeAsync), + // so it's injected here from the token — never taken from the caller. + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("feed")] + [HttpDelete("feed/{postId:guid}/like")] + public async Task>> UnlikeFeed(Guid postId) + => Response(await _feedService.UnlikeAsync(new + { + postId, + userId = DataExtractor.GetUserIdFromContext(HttpContext) + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("feed")] + [HttpGet("feed/{postId:guid}/like")] + public async Task>> IsFeedLiked(Guid postId) + => Response(await _feedService.IsLikedAsync(new { postId }, HttpContext)); + + // ---------- N. Support tickets ---------- + // Lists only the caller's own tickets (GetTicketByUserId, never GetTicketList). + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("tickets")] + [HttpGet("tickets")] + public async Task>> GetMyTickets() + => Response(await _ticketService.GetTicketByUserId(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("tickets")] + [HttpGet("tickets/{id:guid}")] + public async Task>> GetTicket(Guid id) + => Response(await _ticketService.GetTicketById(new { ticketId = id }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("tickets")] + [HttpGet("tickets/categories")] + public async Task>> GetTicketCategories() + => Response(await _ticketService.GetTicketCategoryList(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("tickets")] + [HttpPost("tickets")] + public async Task>> CreateTicket([FromBody] CreateTicketRequest request) + { + var payload = new Dictionary { ["mainReason"] = request.MainReason }; + if (request.TicketCategoryId.HasValue) + payload["ticketCategoryId"] = request.TicketCategoryId.Value; + return Response(await _ticketService.CreateTicket(payload, HttpContext)); + } + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("tickets")] + [HttpPut("tickets/{id:guid}")] + public async Task>> UpdateTicket(Guid id, [FromBody] UpdateTicketRequest request) + { + var payload = new Dictionary { ["ticketId"] = id }; + if (request.MainReason != null) payload["mainReason"] = request.MainReason; + if (request.TicketCategoryId.HasValue) payload["ticketCategoryId"] = request.TicketCategoryId.Value; + return Response(await _ticketService.UpdateTicket(payload, HttpContext)); + } + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("tickets")] + [HttpPost("tickets/{id:guid}/messages")] + public async Task>> SendTicketMessage(Guid id, [FromBody] SendTicketMessageRequest request) + { + var payload = new Dictionary + { + ["ticketId"] = id, + ["message"] = request.Message, + }; + if (request.AttachmentURL != null) payload["attachmentURL"] = request.AttachmentURL; + return Response(await _ticketService.SendMessage(payload, HttpContext)); + } + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("tickets")] + [HttpPost("tickets/{id:guid}/feedback")] + public async Task>> SubmitTicketFeedback(Guid id, [FromBody] TicketFeedbackRequest request) + { + var payload = new Dictionary + { + ["ticketId"] = id, + ["rating"] = request.Rating, + }; + if (request.Feedback != null) payload["feedback"] = request.Feedback; + return Response(await _ticketService.SubmitTicketFeedback(payload, HttpContext)); + } + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("tickets")] + [HttpPost("tickets/{id:guid}/close")] + public async Task>> CloseTicket(Guid id) + => Response(await _ticketService.CloseTicket(new { ticketId = id }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("tickets")] + [HttpDelete("tickets/{id:guid}")] + public async Task>> DeleteTicket(Guid id) + => Response(await _ticketService.DeleteTicket(new { ticketId = id }, HttpContext)); + + // ---------- O. Subscriptions & group members ---------- + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("members")] + [HttpGet("subscriptions/{id:guid}")] + public async Task>> GetSubscription(Guid id) + => Response(await _customerSubscriptionService.GetSubscriptionSummary(new { subscriptionId = id }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("members")] + [HttpPost("subscriptions/{id:guid}/members")] + public async Task>> AddSubscriptionMember(Guid id, [FromBody] AddSubscriptionMemberRequest request) + => Response(await _customerSubscriptionService.AddMemberToSubscription( + new { subscriptionId = id, customerId = request.CustomerId }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("members")] + [HttpDelete("subscriptions/{id:guid}/members/{memberId:guid}")] + public async Task>> RemoveSubscriptionMember(Guid id, Guid memberId) + => Response(await _customerSubscriptionService.RemoveMemberFromSubscription( + new { subscriptionId = id, subscriptionMemberId = memberId }, HttpContext)); + + // Optional extra fees (req 6) — e.g. a coaching fee added once or every renewal. + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("members")] + [HttpGet("subscriptions/{id:guid}/extra-fees")] + public async Task>>> GetSubscriptionExtraFees(Guid id) + => Response(await _customerSubscriptionService.GetExtraFeesAsync(id, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("members")] + [HttpPost("subscriptions/{id:guid}/extra-fees")] + public async Task>> AddSubscriptionExtraFee(Guid id, [FromBody] AddExtraFeeRequest request) + => Response(await _customerSubscriptionService.AddExtraFeeAsync(id, request, HttpContext)); + + // ---------- Request payload helpers & DTOs ---------- + // Services call GetString()/GetBoolean()/GetDecimal() whenever a key exists, + // so omitted fields must be left out of the payload entirely rather than sent null. + + private static Dictionary BuildWorkoutPlanPayload(WorkoutPlanRequest request, Guid? planId = null) + { + var payload = new Dictionary(); + if (planId.HasValue) payload["workoutPlanId"] = planId.Value; + if (request.Name != null) payload["name"] = request.Name; + if (request.Description != null) payload["description"] = request.Description; + if (request.IsTemplate.HasValue) payload["isTemplate"] = request.IsTemplate.Value; + if (request.WorkoutCategoryId.HasValue) payload["workoutCategoryId"] = request.WorkoutCategoryId.Value; + if (request.CustomerIds != null) payload["customerIds"] = request.CustomerIds; + if (request.WorkoutItems.HasValue && request.WorkoutItems.Value.ValueKind == JsonValueKind.Array) + payload["workoutItems"] = request.WorkoutItems.Value; + return payload; + } + + private static Dictionary BuildMealPlanPayload(MealPlanRequest request, Guid? planId = null) + { + var payload = new Dictionary(); + if (planId.HasValue) payload["mealPlanId"] = planId.Value; + if (request.Name != null) payload["name"] = request.Name; + if (request.Description != null) payload["description"] = request.Description; + if (request.IsTemplate.HasValue) payload["isTemplate"] = request.IsTemplate.Value; + if (request.MealCategoryId.HasValue) payload["mealCategoryId"] = request.MealCategoryId.Value; + if (request.CustomerIds != null) payload["customerIds"] = request.CustomerIds; + if (request.MealItems.HasValue && request.MealItems.Value.ValueKind == JsonValueKind.Array) + payload["mealItems"] = request.MealItems.Value; + return payload; + } + + public class WorkoutPlanRequest + { + public string? Name { get; set; } + public string? Description { get; set; } + public bool? IsTemplate { get; set; } + public Guid? WorkoutCategoryId { get; set; } + public List? CustomerIds { get; set; } + /// Array of { exerciseDay, description?, exercises?, howToDo?, doURL? }. + public JsonElement? WorkoutItems { get; set; } + } + + public class WorkoutItemRequest + { + public string ExerciseDay { get; set; } = string.Empty; + public string? Description { get; set; } + public JsonElement? Exercises { get; set; } + public string? HowToDo { get; set; } + public string? DoURL { get; set; } + } + + public class MealItemRequest + { + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + // Sent as strings — the service parses them with decimal.Parse. + public string? Calories { get; set; } + public string? ServingSize { get; set; } + public string? PreparationNotes { get; set; } + } + + public class MealPlanRequest + { + public string? Name { get; set; } + public string? Description { get; set; } + public bool? IsTemplate { get; set; } + public Guid? MealCategoryId { get; set; } + public List? CustomerIds { get; set; } + /// Array of meal items ({ name, description?, calories?, mealItems?, … }). + public JsonElement? MealItems { get; set; } + } + + public class PlanCategoryRequest + { + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + } + + public class PlanAssignRequest + { + public Guid CustomerId { get; set; } + public string? Notes { get; set; } + } + + public class CreateFeedRequest + { + public string? Caption { get; set; } + public string? Description { get; set; } + /// Array of { FileName, Base64 } consumed by DocumentService. + public JsonElement? Document { get; set; } + } + + public class CreateTicketRequest + { + public string MainReason { get; set; } = string.Empty; + public Guid? TicketCategoryId { get; set; } + } + + public class UpdateTicketRequest + { + public string? MainReason { get; set; } + public Guid? TicketCategoryId { get; set; } + } + + public class SendTicketMessageRequest + { + public string Message { get; set; } = string.Empty; + public string? AttachmentURL { get; set; } + } + + public class TicketFeedbackRequest + { + public int Rating { get; set; } + public string? Feedback { get; set; } + } + + public class AddSubscriptionMemberRequest + { + public Guid CustomerId { get; set; } + } + + public class UpdateLockPolicyRequest + { + public int? LockAfterDaysOverdue { get; set; } + } + } +} diff --git a/Controllers/ClientInvoiceController.cs b/Controllers/ClientInvoiceController.cs new file mode 100644 index 0000000..1eef8d9 --- /dev/null +++ b/Controllers/ClientInvoiceController.cs @@ -0,0 +1,135 @@ +using Fitness_core_BE.DTOs.PlatformBilling; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models.IOs; +using Microsoft.AspNetCore.Mvc; +using static Fitness_core_BE.Helpers.JwtMiddleware; +using static Fitness_core_BE.Helpers.ApiResponseHelper; + +namespace Fitness_core_BE.Controllers +{ + // The gym owner's own HexaFitness platform subscription bill — distinct from + // CustomerPaymentsController, which handles invoices billed to gym members. + [ApiController] + [Route("api/client")] + public class ClientInvoiceController : ControllerBase + { + private readonly IClientInvoiceService _clientInvoiceService; + private readonly IPaymentService _paymentService; + private readonly IReceiptService _receiptService; + private readonly IClientPaymentMethodService _clientPaymentMethodService; + private readonly UserRoleHelper _userRoleHelper; + + public ClientInvoiceController( + IClientInvoiceService clientInvoiceService, + IPaymentService paymentService, + IReceiptService receiptService, + IClientPaymentMethodService clientPaymentMethodService, + UserRoleHelper userRoleHelper) + { + _clientInvoiceService = clientInvoiceService; + _paymentService = paymentService; + _receiptService = receiptService; + _clientPaymentMethodService = clientPaymentMethodService; + _userRoleHelper = userRoleHelper; + } + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("billing")] + [HttpGet("customer-invoices")] + public async Task>> GetCustomerInvoices() + { + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(HttpContext)); + return Response(await _clientInvoiceService.GetClientInvoicesByClientId(new { clientId }, HttpContext)); + } + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("billing")] + [HttpGet("customer-invoices/{id:guid}")] + public async Task>> GetCustomerInvoice(Guid id) + => Response(await _clientInvoiceService.GetClientInvoiceById(new { clientInvoiceId = id }, HttpContext)); + + // Pay this gym's own HexaFitness platform invoice. Records a pending payment + // attempt (an admin approves it, which flips the invoice to Paid and issues a receipt). + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("billing")] + [HttpPost("customer-invoices/{id:guid}/pay")] + public async Task>> PayCustomerInvoice(Guid id, [FromBody] PayPlatformInvoiceRequest request) + => Response(await _paymentService.CreateClientPayment(new + { + clientInvoiceId = id, + payAmount = request.PayAmount, + cardId = request.CardId, + payNote = request.PayNote + }, HttpContext)); + + // Branded receipt PDF for a platform invoice (download), once paid. + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("billing")] + [HttpGet("customer-invoices/{id:guid}/receipt.pdf")] + public async Task DownloadPlatformInvoiceReceipt(Guid id) + { + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(HttpContext)); + var model = await _receiptService.BuildForClientInvoiceAsync(id, requireClientId: clientId); + var pdf = _receiptService.GeneratePdf(model); + return File(pdf, "application/pdf", $"{model.ReceiptNo}.pdf"); + } + + // The gym's own saved card(s) for paying their HexaFitness platform subscription + // (real Payment-Service-backed flow — distinct from the manual PayCustomerInvoice + // above, which just records a pending payment for an admin to approve). + + [CustomMiddleware] + [RequiresPermission("billing")] + [HttpPost("customer-invoices/cards/onboard")] + public async Task>> StartCardOnboarding( + [FromBody] StartCardOnboardingRequest request) + => Response(await _clientPaymentMethodService.StartCardOnboardingAsync(HttpContext, request)); + + [CustomMiddleware] + [RequiresPermission("billing")] + [HttpGet("customer-invoices/cards/onboard/complete")] + public async Task>> CompleteCardOnboarding([FromQuery] string sessionId) + => Response(await _clientPaymentMethodService.CompleteCardOnboardingAsync(HttpContext, sessionId)); + + [CustomMiddleware] + [RequiresPermission("billing")] + [HttpGet("customer-invoices/cards")] + public async Task>>> ListCards() + => Response(await _clientPaymentMethodService.ListCardsAsync(HttpContext)); + + [CustomMiddleware] + [RequiresPermission("billing")] + [HttpDelete("customer-invoices/cards/{cardToken}")] + public async Task>> RemoveCard(string cardToken) + { + await _clientPaymentMethodService.RemoveCardAsync(HttpContext, cardToken); + return Response(new { removed = true }); + } + + [CustomMiddleware] + [RequiresPermission("billing")] + [HttpPatch("customer-invoices/cards/{cardToken}/default")] + public async Task>> SetDefaultCard(string cardToken) + => Response(await _clientPaymentMethodService.SetDefaultCardAsync(HttpContext, cardToken)); + + [CustomMiddleware] + [RequiresPermission("billing")] + [HttpGet("customer-invoices/transactions")] + public async Task>> GetTransactionHistory( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20) + => Response(await _clientPaymentMethodService.GetTransactionHistoryAsync(HttpContext, page, pageSize)); + + public class PayPlatformInvoiceRequest + { + public decimal PayAmount { get; set; } + public string? CardId { get; set; } + public string? PayNote { get; set; } + } + } +} diff --git a/Controllers/CustomerController.cs b/Controllers/CustomerController.cs new file mode 100644 index 0000000..f80ca35 --- /dev/null +++ b/Controllers/CustomerController.cs @@ -0,0 +1,715 @@ +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Models.IOs; +using Fitness_core_BE.Services.FunctionHandler; +using Fitness_core_BE.Interfaces.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.StaticFiles; +using System.Text.Json; +using static Fitness_core_BE.Helpers.JwtMiddleware; +using static Fitness_core_BE.Helpers.ApiResponseHelper; +using Fitness_core_BE.DTOs; +using Fitness_core_BE.DTOs.Mobile; + +namespace Fitness_core_BE.Controllers +{ + + [Route("api/app")] + [ApiController] + [Produces("application/json")] + [EnableCors("AllowAll")] + public class CustomerController : ControllerBase + { + private readonly CustomerFunctionHandler _customerFuncHandler; + private readonly MealFunctionHandler _mealFuncHandler; + private readonly WorkoutFunctionHandler _workoutFuncHandler; + private readonly ChatFunctionHandler _chatFuncHandler; + private readonly TicketFuncHandler _ticketFuncHandler; + private readonly DocumentFunctionHandler _documentFuncHandler; + private readonly IDocumentService _docService; + private readonly ICustomerService _customerService; + private readonly IMealService _mealService; + private readonly IWorkoutService _workoutService; + private readonly IChatService _chatService; + private readonly IFeedService _feedService; + private readonly ITicketService _ticketService; + private readonly IAuthService _authService; + + public CustomerController( + CustomerFunctionHandler customerFuncHandler, + MealFunctionHandler mealFuncHandler, + WorkoutFunctionHandler workoutFuncHandler, + ChatFunctionHandler chatFuncHandler, + TicketFuncHandler ticketFuncHandler, + DocumentFunctionHandler documentFuncHandler, + IDocumentService docService, + ICustomerService customerService, + IMealService mealService, + IWorkoutService workoutService, + IChatService chatService, + IFeedService feedService, + ITicketService ticketService, + IAuthService authService) + { + _customerFuncHandler = customerFuncHandler; + _mealFuncHandler = mealFuncHandler; + _workoutFuncHandler = workoutFuncHandler; + _chatFuncHandler = chatFuncHandler; + _ticketFuncHandler = ticketFuncHandler; + _documentFuncHandler = documentFuncHandler; + _docService = docService; + _customerService = customerService; + _mealService = mealService; + _workoutService = workoutService; + _chatService = chatService; + _feedService = feedService; + _ticketService = ticketService; + _authService = authService; + } + private ActionResult> FromAuth(Fitness_core_BE.DTOs.Auth.AuthResponse authResponse) + => Response(authResponse.Data, authResponse.StatusCode, authResponse.Message); + + + private const string VerifyRequiredMessage = + "Please verify your email or mobile number to use this feature."; + + private async Task>?> RequireVerifiedAsync() + { + if (await _customerService.IsCustomerVerified(HttpContext)) + return null; + return Response(null!, 403, VerifyRequiredMessage); + } + + [HttpGet("status")] + [AllowAnonymous] + public IActionResult GetStatus() + { + return Ok(new { status = "Customer API is running", timestamp = System.DateTime.UtcNow }); + } + + // ---------- Auth / account lifecycle (REST) ---------- + + [AllowAnonymous] + [HttpPost("auth/otp")] + public async Task>> SendOtp([FromBody] SendOtpRequest request) + => FromAuth(await _authService.SendOtp(new + { + identifier = request.Identifier, + numberOfDigits = request.NumberOfDigits, + newUser = request.NewUser + }, HttpContext)); + + [AllowAnonymous] + [HttpPost("auth/otp/verify")] + public async Task>> VerifyOtpForLogin([FromBody] VerifyOtpLoginRequest request) + => FromAuth(await _authService.VerifyOtpForLogin(new + { + otpCode = request.OtpCode, + referenceNumber = request.ReferenceNumber + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpPost("auth/otp/verify-alt")] + public async Task>> VerifyOtpAlt([FromBody] VerifyOtpAltRequest request) + => FromAuth(await _authService.VerifyOTP(new + { + otpCode = request.OtpCode, + referenceNumber = request.ReferenceNumber, + identifireType = request.IdentifireType, + identifier = request.Identifier + }, HttpContext)); + + [AllowAnonymous] + [HttpPost("auth/refresh-token")] + public async Task>> RefreshToken([FromBody] RefreshTokenRequest request) + => FromAuth(await _authService.RefreshToken(new { refreshToken = request.RefreshToken }, HttpContext)); + + [AllowAnonymous] + [HttpGet("auth/availability")] + public async Task>> IsAvailable([FromQuery] string identifier) + => FromAuth(await _authService.IsAvailable(new { Identifier = identifier, Recovery = "Recovery" }, HttpContext)); + + // Anonymous so logout still clears cookies even if the access token has expired. + [AllowAnonymous] + [HttpPost("auth/logout")] + public async Task>> Logout() + => FromAuth(await _authService.LogOut(new { }, HttpContext)); + + [AllowAnonymous] + [HttpPost("account/register")] + public async Task>> RegisterCustomer([FromBody] JsonElement request) + => Response(await _customerService.CreateCustomer(request, HttpContext)); + + [AllowAnonymous] + [HttpPost("account/password/forgot")] + public async Task>> ForgotPassword([FromBody] ForgotPasswordRequest request) + => FromAuth(await _authService.ForgotPassword(new + { + identifier = request.Identifier, + useResetLink = request.UseResetLink, + numberOfDigits = request.NumberOfDigits + }, HttpContext)); + + [AllowAnonymous] + [HttpPost("account/password/reset")] + public async Task>> ResetPassword([FromBody] ResetPasswordRequest request) + => FromAuth(await _authService.ResetPassword(new + { + referenceNumber = request.ReferenceNumber, + newPassword = request.NewPassword, + confirmPassword = request.ConfirmPassword + }, HttpContext)); + + // Verify a customer's own email/mobile: validates the OTP (sent via auth/otp with + // newUser:true) and sets the Customer channel flag. Requires an authenticated customer. + [CustomMiddleware] + [TenantResolution] + [HttpPost("account/verify")] + public async Task>> VerifyChannel([FromBody] VerifyChannelRequest request) + => FromAuth(await _authService.VerifyOTPForCus(new + { + identifireType = request.IdentifireType, + otpCode = request.OtpCode, + referenceNumber = request.ReferenceNumber, + identifier = request.Identifier + }, HttpContext)); + + // ---------- Customer-scoped operations ---------- + + [HttpPost("customer")] + [CustomMiddleware] + [TenantResolution] + public async Task ExecuteCustomer([FromBody] ApiRequest request) + { + var response = await _customerFuncHandler.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + [AllowAnonymous] + [HttpGet("customer/exists")] + public async Task>> CustomerExists([FromQuery] string identifier) + => Response(await _customerService.CustomerExists(new { identifier }, HttpContext)); + + + [CustomMiddleware] + [TenantResolution] + [HttpGet("customer/me")] + public async Task>> GetMyProfile() + => Response(await _customerService.GetCusBasicDetails(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpGet("customer/me/gym")] + public async Task>> GetMyGym() + => Response(await _customerService.GetMyGym(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpDelete("customer/me/gym")] + public async Task>> UnfollowMyGym() + => Response(await _customerService.UnfollowMyGym(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpGet("customer/me/payments")] + public async Task>> MyPayment() + => Response(await _customerService.MyPayment(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpPut("customer/me")] + public async Task>> UpdateMyProfile([FromBody] UpdateCustomerProfileRequest request) + => Response(await _customerService.UpdateCustomer(new + { + fullname = request.Fullname, + gender = request.Gender, + bod = request.Bod, + primaryPH = request.PrimaryPH, + secondaryPH = request.SecondaryPH, + email = request.Email, + NIC = request.Nic, + address = request.Address + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpGet("customer/me/health")] + public async Task>> GetMyHealth() + => Response(await _customerService.GetCusHealthDetails(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpPut("customer/me/health")] + public async Task>> UpdateMyHealth([FromBody] UpdateHealthRequest request) + => Response(await _customerService.UpdateCusHealthRec(new + { + bloodGroup = request.BloodGroup, + disabilities = request.Disabilities + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpPost("customer/me/progress")] + public async Task>> CreateMyProgress([FromBody] CreateProgressRequest request) + => Response(await _customerService.CreateCusProgressRec(new + { + Height = request.Height, + currentWeight = request.CurrentWeight, + targetWeight = request.TargetWeight, + goal = request.Goal, + age = request.Age, + gender = request.Gender, + activityLevel = request.ActivityLevel, + timePeriod = request.TimePeriod + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpPut("customer/me/progress")] + public async Task>> UpdateMyProgress([FromBody] UpdateProgressRequest request) + => Response(await _customerService.UpdateCusProgressRec(new + { + Height = request.Height, + Weight = request.Weight, + ProgressDate = request.ProgressDate + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpGet("customer/me/progress")] + public async Task>> GetMyProgress() + => Response(await _customerService.GetCusProgressDetails(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpGet("customer/progress")] + public async Task>> GetCustomerProgress([FromQuery] string? gender, [FromQuery] string? age) + => Response(await _customerService.GetCustomerProgress(new { gender, age }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpGet("customer/progress/weekly/{customerTargetId:guid}")] + public async Task>> GetWeeklyProgress(Guid customerTargetId) + => Response(await _customerService.GetWeeklyProgressForCustomer(new { customerTargetId }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpGet("customer/meal-plans")] + public async Task>> GetMyFollowedMealPlans() + => Response(await _customerService.GetFollowedMealPlans(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpGet("customer/meal-plans/mine")] + public async Task>> GetMyOwnMealPlans() + => Response(await _customerService.GetMyMealPlans(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpGet("customer/workout-plans")] + public async Task>> GetMyFollowedWorkoutPlans() + => Response(await _customerService.GetFollowedWorkoutPlans(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpGet("customer/workout-plans/mine")] + public async Task>> GetMyOwnWorkoutPlans() + => Response(await _customerService.GetMyWorkoutPlans(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpGet("customer/meal-plans/{mealPlanId:guid}")] + public async Task>> GetMyMealPlanById(Guid mealPlanId) + => Response(await _customerService.GetCustomerMealPlanById(new { mealPlanId }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpGet("customer/workout-plans/{workoutPlanId:guid}")] + public async Task>> GetMyWorkoutPlanById(Guid workoutPlanId) + => Response(await _customerService.GetCustomerWorkoutPlanById(new { workoutPlanId }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpPost("customer/meal-plans/{mealPlanId:guid}/follow")] + public async Task>> FollowMealPlan(Guid mealPlanId) + => await RequireVerifiedAsync() + ?? Response(await _customerService.FollowMealPlan(new { mealPlanId }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpPost("customer/workout-plans/{workoutPlanId:guid}/follow")] + public async Task>> FollowWorkoutPlan(Guid workoutPlanId) + => await RequireVerifiedAsync() + ?? Response(await _customerService.FollowWorkOutPlan(new { workOutPlan = workoutPlanId }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpPost("customer/workout-plans/{workoutPlanId:guid}/exercises/{exerciseId:guid}/complete")] + public async Task>> CompleteWorkoutExercise(Guid workoutPlanId, Guid exerciseId) + => Response(await _customerService.SetWorkoutExerciseCompletion(workoutPlanId, exerciseId, true, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpDelete("customer/workout-plans/{workoutPlanId:guid}/exercises/{exerciseId:guid}/complete")] + public async Task>> UncompleteWorkoutExercise(Guid workoutPlanId, Guid exerciseId) + => Response(await _customerService.SetWorkoutExerciseCompletion(workoutPlanId, exerciseId, false, HttpContext)); + + // ---------- Saved cards / auto-deduct consent ---------- + + [CustomMiddleware] + [TenantResolution] + [HttpGet("customer/subscription")] + public async Task>> GetMySubscription() + => Response(await _customerService.GetMySubscription(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpGet("customer/cards")] + public async Task>> GetMyCards() + => Response(await _customerService.GetCardDetails(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpPost("customer/cards")] + public async Task>> SaveCard([FromBody] SaveCardRequest request) + => Response(await _customerService.SaveCard(new + { + cardToken = request.CardToken, + cardLast4 = request.CardLast4, + cardType = request.CardType, + nickName = request.NickName, + consent = request.Consent + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpPatch("customer/subscriptions/{subscriptionId:guid}/auto-charge")] + public async Task>> SetAutoCharge(Guid subscriptionId, [FromBody] SetAutoChargeRequest request) + => Response(await _customerService.SetAutoCharge(subscriptionId, new + { + enabled = request.Enabled, + cardId = request.CardId + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpGet("customer/gyms")] + public async Task>> GetGyms() + => Response(await _customerService.GetClientGyms(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpGet("customer/gyms/{clientBizId:guid}")] + public async Task>> GetGymById(Guid clientBizId) + => Response(await _customerService.GetClientDataById(new { clientBizID = clientBizId }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpGet("customer/gyms/{clientId:guid}/plans")] + public async Task>> GetGymPlans(Guid clientId) + => Response(await _customerService.GetGymPlans(new { clientId }, HttpContext)); + + // Gym-specific Terms & Conditions: view before joining, record acceptance, download PDF. + [CustomMiddleware] + [TenantResolution] + [HttpGet("customer/gyms/{clientId:guid}/terms")] + public async Task>> GetGymTerms(Guid clientId) + => Response(await _customerService.GetGymTerms(new { clientId }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpPost("customer/gyms/{clientId:guid}/terms/accept")] + public async Task>> AcceptGymTerms(Guid clientId, [FromBody] AcceptGymTermsRequest request) + => await RequireVerifiedAsync() + ?? Response(await _customerService.AcceptGymTerms(new { clientId, version = request.Version }, HttpContext)); + + [AllowAnonymous] + [HttpGet("customer/gyms/{clientId:guid}/terms/pdf")] + public async Task DownloadGymTermsPdf(Guid clientId) + { + var pdf = await _customerService.GetGymTermsPdf(clientId); + if (pdf == null) + return NotFound("This gym has not published any terms yet."); + return File(pdf, "application/pdf", "terms-and-conditions.pdf"); + } + + + [CustomMiddleware] + [TenantResolution] + [HttpGet("meal-plans/templates")] + public async Task>> GetMealPlanTemplates() + => Response(await _mealService.GetMealPlanList(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpGet("meal-categories")] + public async Task>> GetMealCategories() + => Response(await _mealService.GetMealCategoryList(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpGet("meal-plans/{mealPlanId:guid}/items")] + public async Task>> GetMealPlanItems(Guid mealPlanId) + => Response(await _mealService.GetMealItemsByMealPlanId(new { mealPlanId }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpPost("meal-plans")] + public async Task>> CreateMealPlan([FromBody] JsonElement request) + => await RequireVerifiedAsync() + ?? Response(await _mealService.CreateCustomerMealPlan(request, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpGet("workout-plans/templates")] + public async Task>> GetWorkoutPlanTemplates() + => Response(await _workoutService.GetWorkoutPlanList(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpGet("workout-categories")] + public async Task>> GetWorkoutCategories() + => Response(await _workoutService.GetWorkoutCategoryList(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpPost("workout-plans")] + public async Task>> CreateWorkoutPlan([FromBody] JsonElement request) + => await RequireVerifiedAsync() + ?? Response(await _workoutService.CreateCustomerWorkout(request, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpPost("chat/meal/sessions")] + public async Task>> CreateMealChatSession([FromBody] GetOrCreateChatRequest request) + => Response(await _chatService.GetOrCreateChatAsync(new { otherUserId = request.OtherUserId })); + + [CustomMiddleware] + [TenantResolution] + [HttpPost("chat/workout/sessions")] + public async Task>> CreateWorkoutChatSession([FromBody] GetOrCreateChatRequest request) + => Response(await _chatService.GetOrCreateChatAsync(new { otherUserId = request.OtherUserId })); + + [CustomMiddleware] + [TenantResolution] + [HttpPost("chat/meal/messages")] + public async Task>> SendMealChatMessage([FromBody] SendChatMessageRequest request) + => Response(await _chatService.SendMessageAsync(new + { + chatId = request.ChatId, + senderId = request.SenderId, + message = request.Message, + type = request.Type + })); + + [CustomMiddleware] + [TenantResolution] + [HttpPost("chat/workout/messages")] + public async Task>> SendWorkoutChatMessage([FromBody] SendChatMessageRequest request) + => Response(await _chatService.SendMessageAsync(new + { + chatId = request.ChatId, + senderId = request.SenderId, + message = request.Message, + type = request.Type + })); + + [HttpPost("meal")] + [CustomMiddleware] + [TenantResolution] + public async Task ExecuteMeal([FromBody] ApiRequest request) + { + var response = await _mealFuncHandler.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + [HttpPost("workout")] + [CustomMiddleware] + [TenantResolution] + public async Task ExecuteWorkout([FromBody] ApiRequest request) + { + var response = await _workoutFuncHandler.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + [HttpPost("chat")] + [CustomMiddleware] + [TenantResolution] + public async Task ExecuteChat([FromBody] ApiRequest request) + { + var response = await _chatFuncHandler.Execute(request); + return StatusCode(response.StatusCode, response); + } + + [HttpPost("initChat")] + [CustomMiddleware] + [TenantResolution] + public async Task InitChat([FromBody] ApiRequest request) + { + var response = await _chatFuncHandler.Execute(request); + return StatusCode(response.StatusCode, response); + } + + // ---------- Feed / tickets / documents (REST) ---------- + + [CustomMiddleware] + [TenantResolution] + [HttpGet("feed")] + public async Task>> GetFeed([FromQuery] int skip = 0, [FromQuery] int take = 5) + => Response(await _feedService.GetFeedForCustomer(new { skip, take }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpPost("feed/{postId:guid}/like")] + public async Task>> LikeFeedPost(Guid postId) + => Response(await _feedService.LikeAsync(new { postId }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpPost("feed")] + public async Task>> CreateFeedPost([FromBody] JsonElement request) + => await RequireVerifiedAsync() + ?? Response(await _customerService.CreateFeed(request, HttpContext)); + + // User-scoped (mirrors the client portal, which also only shows the caller's own + // tickets via GetTicketByUserId, never the unscoped GetTicketList). + [CustomMiddleware] + [TenantResolution] + [HttpGet("tickets")] + public async Task>> GetTickets() + => Response(await _ticketService.GetTicketByUserId(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpGet("tickets/{ticketId:guid}")] + public async Task>> GetTicketById(Guid ticketId) + => Response(await _ticketService.GetTicketById(new { ticketId }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpGet("ticket-categories")] + public async Task>> GetTicketCategories() + => Response(await _ticketService.GetTicketCategoryList(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpPost("tickets")] + public async Task>> CreateTicket([FromBody] CreateTicketRequest request) + => await RequireVerifiedAsync() + ?? Response(await _ticketService.CreateTicket(new + { + mainReason = request.MainReason, + ticketCategoryId = request.TicketCategoryId, + ticketStatus = request.TicketStatus + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpPost("tickets/{ticketId:guid}/messages")] + public async Task>> SendTicketMessage(Guid ticketId, [FromBody] SendTicketMessageRequest request) + => Response(await _ticketService.SendMessage(new { ticketId, message = request.Message }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpPost("tickets/{ticketId:guid}/close")] + public async Task>> CloseTicket(Guid ticketId) + => Response(await _ticketService.CloseTicket(new { ticketId }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpPost("tickets/{ticketId:guid}/feedback")] + public async Task>> SubmitTicketFeedback(Guid ticketId, [FromBody] SubmitTicketFeedbackRequest request) + => Response(await _ticketService.SubmitTicketFeedback(new + { + ticketId, + rating = request.Rating, + feedback = request.Feedback + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpPost("documents")] + public async Task>> UploadDocument([FromBody] JsonElement request) + => Response(await _docService.UploadDocument(request, HttpContext)); + + + [HttpPost("ticket")] + [CustomMiddleware] + [TenantResolution] + public async Task ExecuteTicket([FromBody] ApiRequest request) + { + var response = await _ticketFuncHandler.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + [HttpPost("document")] + [CustomMiddleware] + [TenantResolution] + public async Task ExecuteDocument([FromBody] ApiRequest request) + { + var response = await _documentFuncHandler.Execute(request, HttpContext); + return StatusCode(response.StatusCode, response); + } + + // ---------- Notification tab ---------- + + [CustomMiddleware] + [TenantResolution] + [HttpGet("customer/notifications")] + public async Task>> GetMyNotifications() + => Response(await _customerService.GetMyNotifications(new { }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpPatch("customer/notifications/{notificationId:guid}/read")] + public async Task>> MarkNotificationRead(Guid notificationId) + => Response(await _customerService.MarkNotificationRead(notificationId, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpPut("customer/push-token")] + public async Task>> UpdatePushToken([FromBody] UpdatePushTokenRequest request) + => Response(await _customerService.UpdatePushToken(new { pushTokenId = request.PushTokenId }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpPut("customer/profile-image")] + public async Task>> UpdateProfileImage([FromBody] UpdateProfileImageRequest request) + => Response(await _customerService.UpdateProfileImage(new { documentId = request.DocumentId }, HttpContext)); + + // Serves an uploaded document by id (mobile builds /docService/{id} for assets). + [HttpGet("docService/{docId}")] + [AllowAnonymous] + public async Task GetDocument(string docId) + { + if (!int.TryParse(docId, out int documentId)) + return BadRequest("Invalid Document ID format."); + + DocumentDownloadModel result = await _docService.GetFileBytesById(documentId); + + if (result == null) + return NotFound("Document not found"); + + try + { + byte[] fileBytes = result.FileData; + string fileName = result.FileName; + + var provider = new FileExtensionContentTypeProvider(); + if (!provider.TryGetContentType(fileName, out var contentType)) + { + contentType = "application/octet-stream"; + } + + return File(fileBytes, contentType, fileName); + } + catch (Exception ex) + { + return BadRequest($"Error processing document: {ex.Message}"); + } + } + } +} diff --git a/Controllers/CustomerPaymentsController.cs b/Controllers/CustomerPaymentsController.cs new file mode 100644 index 0000000..66b185b --- /dev/null +++ b/Controllers/CustomerPaymentsController.cs @@ -0,0 +1,154 @@ +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models.IOs; +using Microsoft.AspNetCore.Mvc; +using static Fitness_core_BE.Helpers.JwtMiddleware; +using static Fitness_core_BE.Helpers.ApiResponseHelper; + +namespace Fitness_core_BE.Controllers +{ + // Invoices billed to gym members (CustomerInvoice) and their payments — distinct + // from ClientInvoiceController, which handles the gym owner's own platform bill. + [ApiController] + [Route("api/client")] + public class CustomerPaymentsController : ControllerBase + { + private readonly ICustomerInvoiceService _customerInvoiceService; + private readonly IPaymentService _paymentService; + private readonly IReceiptService _receiptService; + private readonly UserRoleHelper _userRoleHelper; + + public CustomerPaymentsController( + ICustomerInvoiceService customerInvoiceService, + IPaymentService paymentService, + IReceiptService receiptService, + UserRoleHelper userRoleHelper) + { + _customerInvoiceService = customerInvoiceService; + _paymentService = paymentService; + _receiptService = receiptService; + _userRoleHelper = userRoleHelper; + } + + // scope=latest: one row per customer (their latest approved invoice) -- used by the + // regular invoices tab now that a customer can have older Paid/RolledOver/Cancelled + // invoices sitting alongside their current one. Omitted keeps the full, unfiltered + // history for any other existing caller of this endpoint. + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("customer-payments")] + [HttpGet("member-invoices")] + public async Task>> GetMemberInvoices([FromQuery] string? scope = null) + => Response(await _customerInvoiceService.GetCustomerInvoicesByClientId(new { scope }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("customer-payments")] + [HttpGet("member-invoices/{id:guid}")] + public async Task>> GetMemberInvoice(Guid id) + => Response(await _customerInvoiceService.GetCustomerInvoiceById(new { customerInvoiceId = id }, HttpContext)); + + // Full invoice history for one customer (previous cycles) -- used by the regular + // invoices tab's "view previous invoices" action, since the tab itself now only shows + // each customer's latest (see scope=latest above). + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("customer-payments")] + [HttpGet("member-invoices/customer/{customerId:guid}")] + public async Task>> GetMemberInvoicesForCustomer(Guid customerId) + => Response(await _customerInvoiceService.GetCustomerInvoicesByCustomerId(new { customerId }, HttpContext)); + + // Branded "GYM OS" receipt PDF for a member invoice (download). + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("customer-payments")] + [HttpGet("member-invoices/{id:guid}/receipt.pdf")] + public async Task DownloadReceipt(Guid id) + { + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(HttpContext)); + var model = await _receiptService.BuildForInvoiceAsync(id, requireClientId: clientId); + var pdf = _receiptService.GeneratePdf(model); + return File(pdf, "application/pdf", $"{model.ReceiptNo}.pdf"); + } + + // Manually (re)send the receipt to the member via email + SMS. + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("customer-payments")] + [HttpPost("member-invoices/{id:guid}/send-receipt")] + public async Task>> SendReceipt(Guid id) + { + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(HttpContext)); + var model = await _receiptService.BuildForInvoiceAsync(id, requireClientId: clientId); + await _receiptService.SendReceiptAsync(model); + return Response(new { success = true, message = "Receipt sent" }); + } + + // "Charge now" — attempt to auto-deduct from the member's consented saved card. + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("customer-payments")] + [HttpPost("member-invoices/{id:guid}/auto-charge")] + public async Task>> AttemptAutoCharge(Guid id) + => Response(await _paymentService.AttemptAutoCharge(id, HttpContext)); + + // Email/SMS the member a link to pay this invoice online themselves. + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("customer-payments")] + [HttpPost("member-invoices/{id:guid}/payment-link")] + public async Task>> SendPaymentLink(Guid id) + => Response(await _paymentService.SendPaymentLink(id, HttpContext)); + + // ---------- Public pay-by-link page (anonymous; the token itself is the authorization) ---------- + + [HttpGet("pay/{token}")] + public async Task>> GetInvoiceByPaymentToken(string token) + => Response(await _paymentService.GetInvoiceByPaymentToken(token)); + + [HttpPost("pay/{token}/charge")] + public async Task>> ChargeInvoiceByPaymentToken(string token, [FromBody] PayByLinkChargeRequest request) + => Response(await _paymentService.ChargeInvoiceByPaymentToken(token, new { cardToken = request.CardToken })); + + // ---------- Subscription payments ---------- + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("customer-payments")] + [HttpGet("subscriptions/{id:guid}/payments")] + public async Task>> GetSubscriptionPayments(Guid id) + => Response(await _paymentService.GetSubscriptionPayments(new { subscriptionId = id }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("customer-payments")] + [HttpPost("subscriptions/{id:guid}/payments")] + public async Task>> RecordSubscriptionPayment(Guid id, [FromBody] RecordSubscriptionPaymentRequest request) + { + var payload = new Dictionary + { + ["subscriptionId"] = id, + ["amount"] = request.Amount, + ["paymentMethod"] = request.PaymentMethod + }; + if (request.Note != null) payload["note"] = request.Note; + if (request.BankRefNo != null) payload["bankRefNo"] = request.BankRefNo; + return Response(await _paymentService.RecordSubscriptionPayment(payload, HttpContext)); + } + + public class PayByLinkChargeRequest + { + public string CardToken { get; set; } = string.Empty; + } + + public class RecordSubscriptionPaymentRequest + { + public decimal Amount { get; set; } + public string? Note { get; set; } + // "Cash" | "Bank" -- required so the owner picks one instead of it being implicitly cash. + public string PaymentMethod { get; set; } = string.Empty; + // Optional reference/slip number, relevant when PaymentMethod is "Bank". + public string? BankRefNo { get; set; } + } + } +} diff --git a/Controllers/FacilityController.cs b/Controllers/FacilityController.cs new file mode 100644 index 0000000..f723bbf --- /dev/null +++ b/Controllers/FacilityController.cs @@ -0,0 +1,100 @@ +using Fitness_core_BE.DTOs.Facility; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models.IOs; +using Microsoft.AspNetCore.Mvc; +using static Fitness_core_BE.Helpers.JwtMiddleware; +using static Fitness_core_BE.Helpers.ApiResponseHelper; + +namespace Fitness_core_BE.Controllers +{ + // Staff/owner-facing facility + facility-session management. Gated on the "bookings" nav + // permission (NavItem seeded in CentralDbContext.OnModelCreating). + [ApiController] + [Route("api/client/facilities")] + public class FacilityController : ControllerBase + { + private readonly IFacilityService _facilityService; + + public FacilityController(IFacilityService facilityService) + { + _facilityService = facilityService; + } + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("bookings")] + [HttpGet("")] + public async Task>> GetAllFacilities([FromQuery] bool includeInactive = false) + => Response(await _facilityService.GetAllFacilities(new { includeInactive }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("bookings")] + [HttpPost("")] + public async Task>> CreateFacility([FromBody] CreateFacilityRequest request) + => Response(await _facilityService.CreateFacility(new + { + name = request.Name, + facilityType = request.FacilityType, + description = request.Description, + defaultCapacity = request.DefaultCapacity + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("bookings")] + [HttpPut("{id:guid}")] + public async Task>> UpdateFacility(Guid id, [FromBody] UpdateFacilityRequest request) + { + var payload = new Dictionary { ["facilityId"] = id }; + if (request.Name != null) payload["name"] = request.Name; + if (request.FacilityType != null) payload["facilityType"] = request.FacilityType; + if (request.Description != null) payload["description"] = request.Description; + if (request.DefaultCapacity.HasValue) payload["defaultCapacity"] = request.DefaultCapacity.Value; + if (request.IsActive.HasValue) payload["isActive"] = request.IsActive.Value; + + return Response(await _facilityService.UpdateFacility(payload, HttpContext)); + } + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("bookings")] + [HttpPatch("{id:guid}/status")] + public async Task>> SetFacilityStatus(Guid id, [FromBody] SetFacilityStatusRequest request) + => Response(await _facilityService.SetFacilityStatus(new { facilityId = id, isActive = request.IsActive }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("bookings")] + [HttpPost("sessions")] + public async Task>> CreateFacilitySession([FromBody] CreateFacilitySessionRequest request) + => Response(await _facilityService.CreateFacilitySession(new + { + facilityId = request.FacilityId, + startTime = request.StartTime.ToString("o"), + endTime = request.EndTime.ToString("o"), + capacity = request.Capacity + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("bookings")] + [HttpGet("sessions")] + public async Task>> GetSessions( + [FromQuery] Guid? facilityId, [FromQuery] DateTime? fromDate, [FromQuery] DateTime? toDate) + => Response(await _facilityService.GetSessions(new + { + facilityId, + fromDate = fromDate?.ToString("o"), + toDate = toDate?.ToString("o") + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("bookings")] + [HttpDelete("sessions/{id:guid}")] + public async Task>> CancelFacilitySession(Guid id) + => Response(await _facilityService.CancelFacilitySession(new { facilitySessionId = id }, HttpContext)); + } +} diff --git a/Controllers/GymRoleController.cs b/Controllers/GymRoleController.cs new file mode 100644 index 0000000..7450708 --- /dev/null +++ b/Controllers/GymRoleController.cs @@ -0,0 +1,67 @@ +using Fitness_core_BE.DTOs.Rbac; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models.IOs; +using Microsoft.AspNetCore.Mvc; +using static Fitness_core_BE.Helpers.JwtMiddleware; +using static Fitness_core_BE.Helpers.ApiResponseHelper; + +namespace Fitness_core_BE.Controllers +{ + // Owner-only role management. No [RequiresPermission] here on purpose — enforcement + // lives inside IGymRoleService itself (every method requires resolving as the gym + // owner), so no staff nav-section permission can ever unlock these actions. + [ApiController] + [Route("api/client/roles")] + public class GymRoleController : ControllerBase + { + private readonly IGymRoleService _gymRoleService; + + public GymRoleController(IGymRoleService gymRoleService) + { + _gymRoleService = gymRoleService; + } + + [CustomMiddleware] + [TenantResolution] + [HttpGet("")] + public async Task>>> GetRoles() + => Response(await _gymRoleService.GetRolesAsync(HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpPost("")] + public async Task>> CreateRole([FromBody] CreateGymRoleRequest request) + => Response(await _gymRoleService.CreateRoleAsync(request, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpPut("{id:guid}")] + public async Task>> UpdateRole(Guid id, [FromBody] UpdateGymRoleRequest request) + => Response(await _gymRoleService.UpdateRoleAsync(id, request, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpDelete("{id:guid}")] + public async Task>> DeleteRole(Guid id) + { + await _gymRoleService.DeleteRoleAsync(id, HttpContext); + return Response(new { deleted = true }); + } + + [CustomMiddleware] + [TenantResolution] + [HttpPut("{id:guid}/permissions")] + public async Task>> SetRolePermissions(Guid id, [FromBody] SetRolePermissionsRequest request) + => Response(await _gymRoleService.SetRolePermissionsAsync(id, request, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [HttpPut("~/api/client/staff/{staffId:guid}/role")] + public async Task>> AssignStaffRole(Guid staffId, [FromBody] AssignStaffRoleRequest request) + { + await _gymRoleService.AssignStaffRoleAsync(staffId, request, HttpContext); + return Response(new { assigned = true }); + } + } +} diff --git a/Controllers/NavController.cs b/Controllers/NavController.cs new file mode 100644 index 0000000..fb3b380 --- /dev/null +++ b/Controllers/NavController.cs @@ -0,0 +1,32 @@ +using Fitness_core_BE.DTOs.Rbac; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models.IOs; +using Microsoft.AspNetCore.Mvc; +using static Fitness_core_BE.Helpers.JwtMiddleware; +using static Fitness_core_BE.Helpers.ApiResponseHelper; + +namespace Fitness_core_BE.Controllers +{ + // Returns the permission-filtered sidebar nav for the current caller — the owner sees + // everything, staff see only what their assigned role permits. + [ApiController] + [Route("api/client")] + public class NavController : ControllerBase + { + private readonly INavigationService _navigationService; + + public NavController(INavigationService navigationService) + { + _navigationService = navigationService; + } + + [CustomMiddleware] + [TenantResolution] + [HttpGet("nav")] + public async Task>> GetNav() + { + return Response(await _navigationService.GetNavigationAsync(HttpContext)); + } + } +} diff --git a/Controllers/PaymentsEnablementController.cs b/Controllers/PaymentsEnablementController.cs new file mode 100644 index 0000000..549c6db --- /dev/null +++ b/Controllers/PaymentsEnablementController.cs @@ -0,0 +1,78 @@ +using Fitness_core_BE.DTOs.PaymentsEnablement; +using Fitness_core_BE.DTOs.PaymentGateways; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models.IOs; +using Microsoft.AspNetCore.Mvc; +using static Fitness_core_BE.Helpers.JwtMiddleware; +using static Fitness_core_BE.Helpers.ApiResponseHelper; + +namespace Fitness_core_BE.Controllers +{ + // Gym owner's payment-gateway registration/enablement: choosing a payout track + // (own gateway vs. HexaFitness-held), submitting payout KYC, and viewing the + // resulting wallet/payout schedule. The platform-admin equivalent (the global + // gateway catalog) lives in AdminPaymentGatewayController. + [ApiController] + [Route("api/client")] + public class PaymentsEnablementController : ControllerBase + { + private readonly IPaymentsEnablementService _payments; + + public PaymentsEnablementController(IPaymentsEnablementService payments) + { + _payments = payments; + } + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("payment-gateway")] + [HttpGet("payments/tracks")] + public async Task>>> GetPaymentTracks() + => Response(await _payments.GetTrackOptionsAsync()); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("payment-gateway")] + [HttpPost("payments/track-selection")] + public async Task>> SelectTrack([FromBody] SelectPaymentTrackRequest request) + => Response(await _payments.SelectTrackAsync(HttpContext, request)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("payment-gateway")] + [HttpGet("payments/gateways")] + public async Task>>> GetAvailableGateways() + => Response(await _payments.GetAvailableGatewaysAsync(HttpContext.RequestAborted)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("payment-gateway")] + [HttpPost("payments/kyc")] + public async Task>> SubmitKyc( + [FromBody] SubmitGatewayOnboardingRequest request) + => Response(await _payments.SubmitKycAsync( + HttpContext, request, HttpContext.RequestAborted)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("payment-gateway")] + [HttpGet("payments/kyc/status")] + public async Task>> GetKycStatus() + => Response(await _payments.GetKycStatusAsync(HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("payment-gateway")] + [HttpGet("payments/wallet")] + public async Task>> GetWallet() + => Response(await _payments.GetWalletAsync(HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("payment-gateway")] + [HttpGet("payments/payout-schedule")] + public async Task>> GetPayoutSchedule() + => Response(await _payments.GetPayoutScheduleAsync(HttpContext)); + } +} diff --git a/Controllers/PlaceController.cs b/Controllers/PlaceController.cs new file mode 100644 index 0000000..15f71ee --- /dev/null +++ b/Controllers/PlaceController.cs @@ -0,0 +1,71 @@ +using Fitness_core_BE.DTOs.Place; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models.IOs; +using Microsoft.AspNetCore.Mvc; +using static Fitness_core_BE.Helpers.JwtMiddleware; +using static Fitness_core_BE.Helpers.ApiResponseHelper; + +namespace Fitness_core_BE.Controllers +{ + // Staff/owner-facing management of physical access zones (Places) and which fingerprint + // terminal (AccessDevice) sits in each. Gated on "memberships" since Places exist to + // scope membership-plan access, not bookings. + [ApiController] + [Route("api/client/places")] + public class PlaceController : ControllerBase + { + private readonly IPlaceService _placeService; + + public PlaceController(IPlaceService placeService) + { + _placeService = placeService; + } + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("memberships")] + [HttpGet("")] + public async Task>> GetAllPlaces([FromQuery] bool includeInactive = false) + => Response(await _placeService.GetAllPlaces(new { includeInactive }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("memberships")] + [HttpPost("")] + public async Task>> CreatePlace([FromBody] CreatePlaceRequest request) + => Response(await _placeService.CreatePlace(new + { + name = request.Name, + description = request.Description + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("memberships")] + [HttpPut("{id:guid}")] + public async Task>> UpdatePlace(Guid id, [FromBody] UpdatePlaceRequest request) + { + var payload = new Dictionary { ["placeId"] = id }; + if (request.Name != null) payload["name"] = request.Name; + if (request.Description != null) payload["description"] = request.Description; + if (request.IsActive.HasValue) payload["isActive"] = request.IsActive.Value; + + return Response(await _placeService.UpdatePlace(payload, HttpContext)); + } + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("memberships")] + [HttpPatch("{id:guid}/status")] + public async Task>> SetPlaceStatus(Guid id, [FromBody] SetPlaceStatusRequest request) + => Response(await _placeService.SetPlaceStatus(new { placeId = id, isActive = request.IsActive }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("memberships")] + [HttpPut("{id:guid}/device")] + public async Task>> AssignDevice(Guid id, [FromBody] AssignPlaceDeviceRequest request) + => Response(await _placeService.AssignDevice(new { placeId = id, deviceId = request.DeviceId }, HttpContext)); + } +} diff --git a/Controllers/SubscriptionRenewalApprovalController.cs b/Controllers/SubscriptionRenewalApprovalController.cs new file mode 100644 index 0000000..3d4c920 --- /dev/null +++ b/Controllers/SubscriptionRenewalApprovalController.cs @@ -0,0 +1,39 @@ +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models.IOs; +using Microsoft.AspNetCore.Mvc; +using static Fitness_core_BE.Helpers.JwtMiddleware; +using static Fitness_core_BE.Helpers.ApiResponseHelper; + +namespace Fitness_core_BE.Controllers +{ + public record ChangeRenewalPlanRequest(Guid NewCustomerPlanId); + + // Staff-facing approval queue for the unapproved (IsActive=false) invoices the daily + // SubscriptionRenewalJob sweep creates. See ISubscriptionRenewalApprovalService. + [ApiController] + [Route("api/client/renewal-approvals")] + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("customer-payments")] + public class SubscriptionRenewalApprovalController(ISubscriptionRenewalApprovalService approvalService) : ControllerBase + { + private readonly ISubscriptionRenewalApprovalService _approvalService = approvalService; + + [HttpGet] + public async Task>> GetUnapprovedInvoices() + => Response(await _approvalService.GetUnapprovedInvoicesAsync(HttpContext)); + + [HttpPost("{invoiceId:guid}/approve")] + public async Task>> Approve(Guid invoiceId) + => Response(await _approvalService.ApproveAsync(invoiceId, HttpContext)); + + [HttpPost("{invoiceId:guid}/change-plan")] + public async Task>> ChangePlan(Guid invoiceId, [FromBody] ChangeRenewalPlanRequest request) + => Response(await _approvalService.ChangePlanAsync(invoiceId, request.NewCustomerPlanId, HttpContext)); + + [HttpPost("{invoiceId:guid}/reject")] + public async Task>> Reject(Guid invoiceId) + => Response(await _approvalService.RejectAsync(invoiceId, HttpContext)); + } +} diff --git a/Controllers/TimeBankCardController.cs b/Controllers/TimeBankCardController.cs new file mode 100644 index 0000000..fac8283 --- /dev/null +++ b/Controllers/TimeBankCardController.cs @@ -0,0 +1,63 @@ +using Fitness_core_BE.DTOs.TimeBankCard; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models.IOs; +using Microsoft.AspNetCore.Mvc; +using static Fitness_core_BE.Helpers.JwtMiddleware; +using static Fitness_core_BE.Helpers.ApiResponseHelper; + +namespace Fitness_core_BE.Controllers +{ + // Same absolute-per-route reasoning as BookingController: staff issue/swipe under + // "api/client/...", a member's own card list under "api/app/...". This is the + // staff-facing manual swipe terminal; AgentController's own "time-bank-cards/redeem" + // exposes the same tap-in/tap-out swipe to an RFID-reader-equipped agent station instead. + [ApiController] + public class TimeBankCardController : ControllerBase + { + private readonly ITimeBankCardService _timeBankCardService; + + public TimeBankCardController(ITimeBankCardService timeBankCardService) + { + _timeBankCardService = timeBankCardService; + } + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("bookings")] + [HttpPost("api/client/time-bank-cards")] + public async Task>> IssueCard([FromBody] IssueTimeBankCardRequest request) + => Response(await _timeBankCardService.IssueCard(new + { + rfidTagId = request.RfidTagId, + totalMinutesPurchased = request.TotalMinutesPurchased, + amountPaid = request.AmountPaid, + customerId = request.CustomerId, + expiresAt = request.ExpiresAt?.ToString("o") + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("bookings")] + [HttpPost("api/client/time-bank-cards/redeem")] + public async Task>> Swipe([FromBody] SwipeTimeBankCardRequest request) + => Response(await _timeBankCardService.Swipe(new + { + rfidTagId = request.RfidTagId, + facilityId = request.FacilityId + })); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("bookings")] + [HttpGet("api/client/time-bank-cards/{rfidTagId}")] + public async Task>> GetBalance(string rfidTagId) + => Response(await _timeBankCardService.GetBalance(new { rfidTagId })); + + [CustomMiddleware] + [TenantResolution] + [HttpGet("api/app/time-bank-cards/mine")] + public async Task>> GetMyCards() + => Response(await _timeBankCardService.GetMyCards(new { }, HttpContext)); + } +} diff --git a/Controllers/WorkerBillingController.cs b/Controllers/WorkerBillingController.cs new file mode 100644 index 0000000..c68fe5c --- /dev/null +++ b/Controllers/WorkerBillingController.cs @@ -0,0 +1,31 @@ +using Fitness_core_BE.DTOs.PlatformBilling; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models.IOs; +using Microsoft.AspNetCore.Mvc; +using static Fitness_core_BE.Helpers.ApiResponseHelper; + +namespace Fitness_core_BE.Controllers +{ + // Trusted-machine-caller surface for the scheduled subscription-billing worker — not a + // human/agent session, so it's gated by RequireWorkerKeyAttribute (X-Worker-Key) instead + // of CustomMiddleware/JwtMiddleware. + [ApiController] + [Route("api/internal/billing")] + [RequireWorkerKey] + public class WorkerBillingController : ControllerBase + { + private readonly IPlatformBillingService _platformBilling; + + public WorkerBillingController(IPlatformBillingService platformBilling) + { + _platformBilling = platformBilling; + } + + [HttpPost("run-subscription-charges")] + public async Task>> RunSubscriptionCharges() + { + return Response(await _platformBilling.RunAndChargeSubscriptionBillingAsync(HttpContext.RequestAborted)); + } + } +} diff --git a/CustomerProgressFormulas.txt b/CustomerProgressFormulas.txt new file mode 100644 index 0000000..0416ef0 --- /dev/null +++ b/CustomerProgressFormulas.txt @@ -0,0 +1,68 @@ +Customer Progress Prediction Formulas + +Required input data +- Current weight in kg +- Height in cm +- Age in years +- Gender: Male or Female +- Goal: Weight Loss, Weight Gain, Maintain Weight, Muscle Gain, or Fat Loss +- Target weight in kg +- Time period in months +- Activity level + +Activity level multipliers +- Sedentary: 1.2 +- Lightly Active: 1.375 +- Moderately Active: 1.55 +- Very Active: 1.725 +- Athlete: 1.9 + +BMR formula: Mifflin-St Jeor +- Male BMR = (10 x weight kg) + (6.25 x height cm) - (5 x age) + 5 +- Female BMR = (10 x weight kg) + (6.25 x height cm) - (5 x age) - 161 + +TDEE formula +- TDEE = BMR x activity multiplier + +Goal calorie adjustment +- Weight Loss and Fat Loss use a calorie deficit. +- Weight Gain and Muscle Gain use a calorie surplus. +- Maintain Weight uses no calorie adjustment. +- Required daily calorie change = (required weekly weight change kg x 7700) / 7 +- Weight loss deficit is capped at 700 kcal/day for safety. +- Weight gain surplus is capped at 400 kcal/day for safety. +- If the target requires a smaller change than the usual coaching range, the smaller target-driven change is used instead of forcing a larger adjustment. + +Weight change estimate +- 1 kg body fat is approximated as 7700 calories. +- Required weekly weight change = abs(current weight - target weight) / available weeks +- Available weeks = time period months x 4.33 +- Predicted weekly weight change = (daily calorie adjustment x 7) / 7700 +- Predicted future weight = current weight +/- (predicted weekly weight change x week number) +- Predictions are capped at the target weight so they do not overshoot the goal. + +Macro formulas +- Protein = current weight kg x protein factor +- Fat grams = (target calories x 0.25) / 9 +- Carb grams = (target calories - ((protein grams x 4) + (fat grams x 9))) / 4 + +Protein factors used +- Weight Loss: 2.0 g/kg +- Fat Loss: 2.0 g/kg +- Muscle Gain: 2.0 g/kg +- Weight Gain: 1.8 g/kg +- Maintain Weight: 1.6 g/kg + +Macro calorie values +- Protein: 4 kcal/g +- Carbs: 4 kcal/g +- Fat: 9 kcal/g + +Difficulty level +- Easy: required weekly change <= 0.5 kg/week +- Moderate: required weekly change > 0.5 and <= 0.75 kg/week +- Hard: required weekly change > 0.75 and <= 1.0 kg/week +- Extreme / Unsafe: required weekly change > 1.0 kg/week + +Additional calculated output +- BMI = weight kg / (height meters x height meters) diff --git a/DTOs/AccessDevice/AccessDeviceDtos.cs b/DTOs/AccessDevice/AccessDeviceDtos.cs new file mode 100644 index 0000000..06a5f5d --- /dev/null +++ b/DTOs/AccessDevice/AccessDeviceDtos.cs @@ -0,0 +1,47 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fitness_core_BE.DTOs.AccessDevice +{ + public class UnlinkedFingerprintDto + { + public Guid Id { get; set; } + public int DeviceUserId { get; set; } + public string DeviceEmployeeNo { get; set; } = string.Empty; + public string? DeviceReportedName { get; set; } + public string? DeviceUserType { get; set; } + public int FingerprintCount { get; set; } + public Guid DeviceId { get; set; } + public string? DeviceName { get; set; } + public DateTime DiscoveredAt { get; set; } + } + + public class LinkFingerprintRequest + { + public Guid UnlinkedEnrollmentId { get; set; } + } + + /// + /// Body of POST client/customers/{id}/face-photo. The browser resizes and re-encodes to + /// JPEG before sending, so this carries a ready-to-enrol image; a data: prefix is accepted. + /// + public class SetFacePhotoRequest + { + [Required] + public string ImageBase64 { get; set; } = string.Empty; + } + + public class AccessStatusDto + { + public long AccessProfileId { get; set; } + public string AccessNumber { get; set; } = string.Empty; + public string PersonType { get; set; } = string.Empty; + public string DesiredAccessState { get; set; } = string.Empty; + public string ProvisionStatus { get; set; } = "NoDevice"; + public string ActualAccessState { get; set; } = "Unknown"; + public int FingerprintCount { get; set; } + public string? LastError { get; set; } + public DateTime? LastSyncedAt { get; set; } + public int DeviceCount { get; set; } + public int SyncedDeviceCount { get; set; } + } +} diff --git a/DTOs/Agent/AgentRequests.cs b/DTOs/Agent/AgentRequests.cs new file mode 100644 index 0000000..8bf9654 --- /dev/null +++ b/DTOs/Agent/AgentRequests.cs @@ -0,0 +1,76 @@ +namespace Fitness_core_BE.DTOs.Agent +{ + // POST /api/agent/enroll (bootstrap, no api-key auth) + public class EnrollRequest + { + public string Code { get; set; } + public string AgentName { get; set; } + } + + // POST /api/agent/devices + public class RegisterDeviceRequest + { + public string DeviceName { get; set; } + public Guid? DeviceId { get; set; } + public string? HardwareId { get; set; } + } + + // POST /api/agent/attendance + public class AgentAttendanceRequest + { + public Guid DeviceId { get; set; } + public List Events { get; set; } = new(); + } + + public class AgentAttendanceEvent + { + public string EmployeeNoString { get; set; } + public DateTime Time { get; set; } + public string AttendanceStatus { get; set; } + } + + // POST /api/agent/commands/{id}/result + public class CommandResultRequest + { + public bool Success { get; set; } + public string? ResultJson { get; set; } + } + + // Full person inventory contains metadata only. Fingerprint template bytes stay + // on the terminal and are never uploaded to the cloud. + public class DeviceInventoryRequest + { + public Guid DeviceId { get; set; } + public List People { get; set; } = new(); + } + + public class DeviceInventoryPerson + { + public string EmployeeNo { get; set; } = string.Empty; + public string? Name { get; set; } + public string? UserType { get; set; } + public int FingerprintCount { get; set; } + } + + // POST /api/agent/heartbeat. Device reachability is measured on the LAN by + // the agent; the cloud never attempts to connect to a private terminal. + public class AgentHeartbeatRequest + { + public List Devices { get; set; } = new(); + } + + public class AgentDeviceHealthReport + { + public Guid DeviceId { get; set; } + public bool IsOnline { get; set; } + } + + // POST /api/agent/time-bank-cards/redeem -- an RFID-reader-equipped agent station + // reporting a card swipe (tap-in or tap-out, see TimeBankCardService.Swipe). FacilityId is + // the reader's own fixed station, not something read off the card. + public class AgentSwipeTimeBankCardRequest + { + public string RfidTagId { get; set; } = string.Empty; + public Guid FacilityId { get; set; } + } +} diff --git a/DTOs/Attendance/AttendanceCountDTO.cs b/DTOs/Attendance/AttendanceCountDTO.cs new file mode 100644 index 0000000..46732f4 --- /dev/null +++ b/DTOs/Attendance/AttendanceCountDTO.cs @@ -0,0 +1,9 @@ +namespace Fitness_core_BE.DTOs.Attendance +{ + public class AttendanceCountDTO + { + public Guid CustomerId { get; set; } + public string? CustomerName { get; set; } + public int AttendanceCount { get; set; } + } +} diff --git a/DTOs/Attendance/AttendanceDTO.cs b/DTOs/Attendance/AttendanceDTO.cs new file mode 100644 index 0000000..3808456 --- /dev/null +++ b/DTOs/Attendance/AttendanceDTO.cs @@ -0,0 +1,14 @@ +namespace Fitness_core_BE.DTOs +{ + public class AttendanceDTO + { + public Guid DeviceId { get; set; } + public Guid? CustomerId { get; set; } + public Guid UserId { get; set; } + public long AccessProfileId { get; set; } + public int DeviceUserId { get; set; } + public string DeviceEmployeeNo { get; set; } = string.Empty; + public DateTime EventTime { get; set; } + public string EventType { get; set; } + } +} diff --git a/DTOs/Attendance/TodayAttendanceDTO.cs b/DTOs/Attendance/TodayAttendanceDTO.cs new file mode 100644 index 0000000..8598ac5 --- /dev/null +++ b/DTOs/Attendance/TodayAttendanceDTO.cs @@ -0,0 +1,9 @@ +namespace Fitness_core_BE.DTOs.Attendance +{ + public class TodayAttendanceDTO + { + public string? CustomerName { get; set; } + public DateTime EventTime { get; set; } + public string? EventType { get; set; } + } +} diff --git a/DTOs/Auth/AuthResponse.cs b/DTOs/Auth/AuthResponse.cs new file mode 100644 index 0000000..332aa9e --- /dev/null +++ b/DTOs/Auth/AuthResponse.cs @@ -0,0 +1,10 @@ +namespace Fitness_core_BE.DTOs.Auth +{ + public class AuthResponse + { + public int StatusCode { get; set; } + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; + public object? Data { get; set; } + } +} diff --git a/DTOs/Booking/BookingDto.cs b/DTOs/Booking/BookingDto.cs new file mode 100644 index 0000000..9a58ed0 --- /dev/null +++ b/DTOs/Booking/BookingDto.cs @@ -0,0 +1,31 @@ +namespace Fitness_core_BE.DTOs.Booking +{ + public class BookingDto + { + public Guid BookingId { get; set; } + public Guid FacilitySessionId { get; set; } + public string? FacilityName { get; set; } + public DateTime? SessionStartTime { get; set; } + public DateTime? SessionEndTime { get; set; } + + public bool IsGuest { get; set; } + public Guid? CustomerId { get; set; } + public string? GuestName { get; set; } + public string? GuestPhone { get; set; } + + /// Only populated in the response right after booking creation -- the + /// guest's receipt. Never returned by list/lookup endpoints afterward. + public string? OtpCode { get; set; } + public bool OtpVerified { get; set; } + + public string PaymentStatus { get; set; } = string.Empty; + public decimal AmountCharged { get; set; } + public string Status { get; set; } = string.Empty; + public DateTime BookedAt { get; set; } + public DateTime? CheckedInAt { get; set; } + + /// A public, unauthenticated link to this booking's confirmation page -- + /// copy-paste-and-share receipt. Works for both member and guest bookings. + public string? ShareUrl { get; set; } + } +} diff --git a/DTOs/Booking/BookingRequests.cs b/DTOs/Booking/BookingRequests.cs new file mode 100644 index 0000000..652d5d0 --- /dev/null +++ b/DTOs/Booking/BookingRequests.cs @@ -0,0 +1,44 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fitness_core_BE.DTOs.Booking +{ + public class CreateMemberBookingRequest + { + [Required] + public Guid FacilitySessionId { get; set; } + } + + public class CreateGuestBookingRequest + { + [Required] + public Guid FacilitySessionId { get; set; } + + [Required] + public string GuestName { get; set; } = string.Empty; + + [Required] + public string GuestPhone { get; set; } = string.Empty; + } + + public class MarkGuestBookingPaidRequest + { + [Required] + public Guid BookingId { get; set; } + + [Required] + [Range(0.01, double.MaxValue, ErrorMessage = "AmountCharged must be greater than zero")] + public decimal AmountCharged { get; set; } + } + + public class CheckInBookingRequest + { + [Required] + public string OtpCode { get; set; } = string.Empty; + } + + public class CancelBookingRequest + { + [Required] + public Guid BookingId { get; set; } + } +} diff --git a/DTOs/ClientInvoice/ClientInvoiceDto.cs b/DTOs/ClientInvoice/ClientInvoiceDto.cs new file mode 100644 index 0000000..76ac537 --- /dev/null +++ b/DTOs/ClientInvoice/ClientInvoiceDto.cs @@ -0,0 +1,15 @@ +namespace Fitness_core_BE.DTOs.ClientInvoice +{ + public class ClientInvoiceDto + { + public Guid ClientInvoiceId { get; set; } + public Guid SubscriptionId { get; set; } + public string? ClientName { get; set; } + public string? UserEmail { get; set; } + public string? PackageName { get; set; } + public decimal Amount { get; set; } + public string Status { get; set; } = string.Empty; + public DateTime ClientInvoiceDate { get; set; } + public List Items { get; set; } = new List(); + } +} diff --git a/DTOs/ClientInvoice/ClientInvoiceItemDto.cs b/DTOs/ClientInvoice/ClientInvoiceItemDto.cs new file mode 100644 index 0000000..095492d --- /dev/null +++ b/DTOs/ClientInvoice/ClientInvoiceItemDto.cs @@ -0,0 +1,11 @@ +namespace Fitness_core_BE.DTOs.ClientInvoice +{ + public class ClientInvoiceItemDto + { + public Guid ClientInvoiceItemId { get; set; } + public string? ItemName { get; set; } + public string? ItemDescription { get; set; } + public decimal ItemPrice { get; set; } + public DateTime CreatedAt { get; set; } + } +} diff --git a/DTOs/ClientInvoice/CreateClientInvoiceRequest.cs b/DTOs/ClientInvoice/CreateClientInvoiceRequest.cs new file mode 100644 index 0000000..e42e9de --- /dev/null +++ b/DTOs/ClientInvoice/CreateClientInvoiceRequest.cs @@ -0,0 +1,7 @@ +namespace Fitness_core_BE.DTOs.ClientInvoice +{ + public class CreateClientInvoiceRequest + { + public Guid SubscriptionId { get; set; } + } +} diff --git a/DTOs/ClientInvoice/UpdateClientInvoiceStatusRequest.cs b/DTOs/ClientInvoice/UpdateClientInvoiceStatusRequest.cs new file mode 100644 index 0000000..1d526d2 --- /dev/null +++ b/DTOs/ClientInvoice/UpdateClientInvoiceStatusRequest.cs @@ -0,0 +1,8 @@ +namespace Fitness_core_BE.DTOs.ClientInvoice +{ + public class UpdateClientInvoiceStatusRequest + { + public Guid ClientInvoiceId { get; set; } + public string Status { get; set; } = string.Empty; + } +} diff --git a/DTOs/Customer/CreateCustomerRequest.cs b/DTOs/Customer/CreateCustomerRequest.cs new file mode 100644 index 0000000..d9c89e7 --- /dev/null +++ b/DTOs/Customer/CreateCustomerRequest.cs @@ -0,0 +1,14 @@ +namespace Fitness_core_BE.DTOs.Customer +{ + public class CreateCustomerRequest + { + // Customer details + public string CusName { get; set; } = string.Empty; + + // User details + public string UserName { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string PhoneNumber { get; set; } = string.Empty; + public bool IsActive { get; set; } = true; + } +} diff --git a/DTOs/Customer/CustomerDto.cs b/DTOs/Customer/CustomerDto.cs new file mode 100644 index 0000000..57ff2f3 --- /dev/null +++ b/DTOs/Customer/CustomerDto.cs @@ -0,0 +1,11 @@ +namespace Fitness_core_BE.DTOs.Customer +{ + public class CustomerDto + { + public Guid CusId { get; set; } + public string CusName { get; set; } = string.Empty; + public Guid FKUserId { get; set; } + public string? UserName { get; set; } + public string? UserEmail { get; set; } + } +} diff --git a/DTOs/Customer/FollowedPlanDto.cs b/DTOs/Customer/FollowedPlanDto.cs new file mode 100644 index 0000000..c788eb3 --- /dev/null +++ b/DTOs/Customer/FollowedPlanDto.cs @@ -0,0 +1,4 @@ +namespace Fitness_core_BE.DTOs.Customer +{ + public record FollowedPlanDto(Guid PlanId, string? Name, bool IsFollowing, DateTime? CompletedDate); +} diff --git a/DTOs/Customer/MemberListRowDto.cs b/DTOs/Customer/MemberListRowDto.cs new file mode 100644 index 0000000..e8decbf --- /dev/null +++ b/DTOs/Customer/MemberListRowDto.cs @@ -0,0 +1,56 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.DTOs.Customer +{ + /// + /// One row per gym member for GET api/client/customers. Deliberate superset of + /// the previous CustomerSubscription row shape so the frontend change is additive: + /// group members each get their own row pointing at the shared subscription. + /// + public class MemberListRowDto + { + public Guid SubscriptionId { get; set; } + public Guid FKCustomerPlanId { get; set; } + public Guid FKCustomerId { get; set; } + public DateTime OnboardDate { get; set; } + public DateTime? ExpiryDate { get; set; } + public DateTime? RenewalDate { get; set; } + public bool IsActive { get; set; } + public DateTime CreatedAt { get; set; } + public Models.Customer? Customer { get; set; } + + public bool IsPrimary { get; set; } + /// False when the member has been unassigned from their plan but is still a member of the gym. + public bool IsPlanAssigned { get; set; } = true; + public string? PlanName { get; set; } + public string PlanScope { get; set; } = "Individual"; + public int MemberCount { get; set; } = 1; + public int MemberLimit { get; set; } = 1; + public decimal InvoiceAmount { get; set; } + public decimal PaidAmount { get; set; } + public decimal Balance { get; set; } + public string PaymentStatus { get; set; } = "Pending"; + public bool AllowSplitPayments { get; set; } + + // Linked fingerprint/device-access enrollment, if any (name may be null when the + // device didn't report one at enrollment time). + public int? FingerprintId { get; set; } + public string? FingerprintName { get; set; } + public long? AccessProfileId { get; set; } + public string? AccessNumber { get; set; } + public string AccessProvisionStatus { get; set; } = "NotConfigured"; + public string ActualAccessState { get; set; } = "Unknown"; + public string DesiredAccessState { get; set; } = "Enabled"; + public int FingerprintCount { get; set; } + public string? AccessLastError { get; set; } + + public bool IsLocked { get; set; } + public string? LockReason { get; set; } + + /// Face-photo enrolment on the gym's access terminals. + public bool HasFacePhoto { get; set; } + /// "None" | "Pending" | "Synced" | "Failed". + public string FacePhotoStatus { get; set; } = "None"; + public string? FacePhotoError { get; set; } + } +} diff --git a/DTOs/Customer/UpdateCustomerRequest.cs b/DTOs/Customer/UpdateCustomerRequest.cs new file mode 100644 index 0000000..a04642a --- /dev/null +++ b/DTOs/Customer/UpdateCustomerRequest.cs @@ -0,0 +1,14 @@ +namespace Fitness_core_BE.DTOs.Customer +{ + public class UpdateCustomerRequest + { + // Customer details + public string? CusName { get; set; } + + // User details + public string? UserName { get; set; } + public string? Email { get; set; } + public string? PhoneNumber { get; set; } + public bool? IsActive { get; set; } + } +} diff --git a/DTOs/CustomerInvoice/CreateCustomerInvoiceRequest.cs b/DTOs/CustomerInvoice/CreateCustomerInvoiceRequest.cs new file mode 100644 index 0000000..74d6a15 --- /dev/null +++ b/DTOs/CustomerInvoice/CreateCustomerInvoiceRequest.cs @@ -0,0 +1,7 @@ +namespace Fitness_core_BE.DTOs.CustomerInvoice +{ + public class CreateCustomerInvoiceRequest + { + public Guid SubscriptionId { get; set; } + } +} diff --git a/DTOs/CustomerInvoice/CustomerInvoiceDto.cs b/DTOs/CustomerInvoice/CustomerInvoiceDto.cs new file mode 100644 index 0000000..c3d5798 --- /dev/null +++ b/DTOs/CustomerInvoice/CustomerInvoiceDto.cs @@ -0,0 +1,17 @@ +namespace Fitness_core_BE.DTOs.CustomerInvoice +{ + public class CustomerInvoiceDto + { + public Guid CustomerInvoiceId { get; set; } + public Guid SubscriptionId { get; set; } + public Guid FKClientId { get; set; } + public string? ClientName { get; set; } + public string? CustomerName { get; set; } + public string? UserEmail { get; set; } + public string? PackageName { get; set; } + public decimal Amount { get; set; } + public string Status { get; set; } = string.Empty; + public DateTime CustomerInvoiceDate { get; set; } + public List Items { get; set; } = new List(); + } +} diff --git a/DTOs/CustomerInvoice/CustomerInvoiceItemDto.cs b/DTOs/CustomerInvoice/CustomerInvoiceItemDto.cs new file mode 100644 index 0000000..f2e32d5 --- /dev/null +++ b/DTOs/CustomerInvoice/CustomerInvoiceItemDto.cs @@ -0,0 +1,11 @@ +namespace Fitness_core_BE.DTOs.CustomerInvoice +{ + public class CustomerInvoiceItemDto + { + public Guid CustomerInvoiceItemId { get; set; } + public string? ItemName { get; set; } + public string? ItemDescription { get; set; } + public decimal ItemPrice { get; set; } + public DateTime CreatedAt { get; set; } + } +} diff --git a/DTOs/CustomerInvoice/UpdateCustomerInvoiceStatusRequest.cs b/DTOs/CustomerInvoice/UpdateCustomerInvoiceStatusRequest.cs new file mode 100644 index 0000000..bf5eb34 --- /dev/null +++ b/DTOs/CustomerInvoice/UpdateCustomerInvoiceStatusRequest.cs @@ -0,0 +1,8 @@ +namespace Fitness_core_BE.DTOs.CustomerInvoice +{ + public class UpdateCustomerInvoiceStatusRequest + { + public Guid CustomerInvoiceId { get; set; } + public string Status { get; set; } = string.Empty; + } +} diff --git a/DTOs/CustomerPlan/CreateCustomerPlanRequest.cs b/DTOs/CustomerPlan/CreateCustomerPlanRequest.cs new file mode 100644 index 0000000..b608af1 --- /dev/null +++ b/DTOs/CustomerPlan/CreateCustomerPlanRequest.cs @@ -0,0 +1,62 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fitness_core_BE.DTOs.CustomerPlan +{ + public class CreateCustomerPlanRequest + { + [Required] + public string PlanName { get; set; } = string.Empty; + + public string? PlanDescription { get; set; } + + [Required] + [Range(0, double.MaxValue, ErrorMessage = "Plan price must be a positive value")] + public decimal PlanPrice { get; set; } + + public bool? HasOnboardingFee { get; set; } + + [Range(0, double.MaxValue, ErrorMessage = "Onboarding fee must be zero or greater")] + public decimal? OnboardingFeeAmount { get; set; } + + /// Required when DurationMode is "Rolling" (validated in the service). + [Range(1, int.MaxValue, ErrorMessage = "Duration must be at least 1 day")] + public int? DurationInDays { get; set; } + + /// "Individual" (default) | "Group". + public string? PlanScope { get; set; } + + /// Required >= 2 when PlanScope is "Group". + public int? MemberLimit { get; set; } + + /// "Rolling" (default) | "FixedRange". + public string? DurationMode { get; set; } + + public DateTime? ValidFrom { get; set; } + public DateTime? ValidTo { get; set; } + + public bool? AllowSplitPayments { get; set; } + + /// Number of mostly-equal installments; defaults to plan duration in months when split is enabled. + public int? InstallmentCount { get; set; } + + /// Null (default) = unlimited-access plan. A number = this plan grants that + /// many bookable Facility sessions per billing cycle (e.g. "4 pool sessions a month"). + [Range(1, int.MaxValue, ErrorMessage = "SessionsPerCycle must be at least 1 when set")] + public int? SessionsPerCycle { get; set; } + + /// "NoRollover" (default) | "GracePeriod". Only meaningful when SessionsPerCycle is set. + public string? SessionRolloverPolicy { get; set; } + + /// Required (>= 1) when SessionRolloverPolicy is "GracePeriod". + [Range(0, int.MaxValue, ErrorMessage = "RolloverGraceDays must be zero or greater")] + public int? RolloverGraceDays { get; set; } + + /// "Prepaid" (default, pay before the cycle starts) | "Postpaid" (use first, + /// invoice at cycle end, access suspended if unpaid past due). Asked once per package. + public string? BillingTiming { get; set; } + + /// Places this plan grants access to. Null/omitted or empty = unrestricted + /// (reaches every active access device, today's behavior). + public List? PlaceIds { get; set; } + } +} diff --git a/DTOs/CustomerPlan/CustomerPlanDto.cs b/DTOs/CustomerPlan/CustomerPlanDto.cs new file mode 100644 index 0000000..6bd1e1c --- /dev/null +++ b/DTOs/CustomerPlan/CustomerPlanDto.cs @@ -0,0 +1,33 @@ +namespace Fitness_core_BE.DTOs.CustomerPlan +{ + public class CustomerPlanDto + { + public Guid CustomerPlanId { get; set; } + public Guid FKClientId { get; set; } + public string? ClientName { get; set; } + public string? PlanName { get; set; } + public string? PlanDescription { get; set; } + public decimal PlanPrice { get; set; } + public bool HasOnboardingFee { get; set; } + public decimal OnboardingFeeAmount { get; set; } + public int DurationInDays { get; set; } + public string PlanScope { get; set; } = "Individual"; + public int MemberLimit { get; set; } = 1; + public string DurationMode { get; set; } = "Rolling"; + public DateTime? ValidFrom { get; set; } + public DateTime? ValidTo { get; set; } + public bool AllowSplitPayments { get; set; } + public int InstallmentCount { get; set; } = 1; + public int? SessionsPerCycle { get; set; } + public string SessionRolloverPolicy { get; set; } = "NoRollover"; + public int RolloverGraceDays { get; set; } + public string BillingTiming { get; set; } = "Prepaid"; + public bool IsActive { get; set; } + public DateTime CreatedAt { get; set; } + + // Which Places this plan grants access to. Empty = unrestricted (reaches every + // active device) -- see AccessDeviceService's allowed-device resolution. + public List PlaceIds { get; set; } = new(); + public List PlaceNames { get; set; } = new(); + } +} diff --git a/DTOs/CustomerPlan/SetCustomerPlanStatusRequest.cs b/DTOs/CustomerPlan/SetCustomerPlanStatusRequest.cs new file mode 100644 index 0000000..4f11e95 --- /dev/null +++ b/DTOs/CustomerPlan/SetCustomerPlanStatusRequest.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fitness_core_BE.DTOs.CustomerPlan +{ + /// + /// Body of PATCH client/plans/{id}/status -- activate or deactivate a membership plan + /// without resending its configuration. + /// + public class SetCustomerPlanStatusRequest + { + [Required] + public bool IsActive { get; set; } + } +} diff --git a/DTOs/CustomerPlan/UpdateCustomerPlanRequest.cs b/DTOs/CustomerPlan/UpdateCustomerPlanRequest.cs new file mode 100644 index 0000000..49c8fdf --- /dev/null +++ b/DTOs/CustomerPlan/UpdateCustomerPlanRequest.cs @@ -0,0 +1,54 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fitness_core_BE.DTOs.CustomerPlan +{ + public class UpdateCustomerPlanRequest + { + [Required] + public Guid CustomerPlanId { get; set; } + + public string? PlanName { get; set; } + + public string? PlanDescription { get; set; } + + [Range(0, double.MaxValue, ErrorMessage = "Plan price must be a positive value")] + public decimal? PlanPrice { get; set; } + + public bool? HasOnboardingFee { get; set; } + + [Range(0, double.MaxValue, ErrorMessage = "Onboarding fee must be zero or greater")] + public decimal? OnboardingFeeAmount { get; set; } + + [Range(1, int.MaxValue, ErrorMessage = "Duration must be at least 1 day")] + public int? DurationInDays { get; set; } + + public string? PlanScope { get; set; } + + public int? MemberLimit { get; set; } + + public string? DurationMode { get; set; } + + public DateTime? ValidFrom { get; set; } + public DateTime? ValidTo { get; set; } + + public bool? AllowSplitPayments { get; set; } + + public int? InstallmentCount { get; set; } + + [Range(1, int.MaxValue, ErrorMessage = "SessionsPerCycle must be at least 1 when set")] + public int? SessionsPerCycle { get; set; } + + public string? SessionRolloverPolicy { get; set; } + + [Range(0, int.MaxValue, ErrorMessage = "RolloverGraceDays must be zero or greater")] + public int? RolloverGraceDays { get; set; } + + public string? BillingTiming { get; set; } + + public bool? IsActive { get; set; } + + /// Replaces this plan's place set when provided (empty array clears it back + /// to unrestricted). Omit/null to leave the plan's places unchanged. + public List? PlaceIds { get; set; } + } +} diff --git a/DTOs/CustomerSubscription/CreateCustomerSubscriptionRequest.cs b/DTOs/CustomerSubscription/CreateCustomerSubscriptionRequest.cs new file mode 100644 index 0000000..d77c160 --- /dev/null +++ b/DTOs/CustomerSubscription/CreateCustomerSubscriptionRequest.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fitness_core_BE.DTOs.CustomerSubscription +{ + public class CreateCustomerSubscriptionRequest + { + [Required] + public Guid CustomerId { get; set; } + + [Required] + public Guid CustomerPlanId { get; set; } + + public DateTime? OnboardDate { get; set; } + + public DateTime? ExpiryDate { get; set; } + } +} diff --git a/DTOs/CustomerSubscription/CustomerSubscriptionDto.cs b/DTOs/CustomerSubscription/CustomerSubscriptionDto.cs new file mode 100644 index 0000000..a8dd61e --- /dev/null +++ b/DTOs/CustomerSubscription/CustomerSubscriptionDto.cs @@ -0,0 +1,40 @@ +namespace Fitness_core_BE.DTOs.CustomerSubscription +{ + public class CustomerSubscriptionDto + { + public Guid SubscriptionId { get; set; } + public Guid CustomerId { get; set; } + public string? CustomerName { get; set; } + public Guid CustomerPlanId { get; set; } + public string? PlanName { get; set; } + public decimal PlanPrice { get; set; } + public int DurationInDays { get; set; } + public DateTime OnboardDate { get; set; } + public DateTime? ExpiryDate { get; set; } + public DateTime? RenewalDate { get; set; } + public bool IsActive { get; set; } + public DateTime CreatedAt { get; set; } + public string? DiscountType { get; set; } + public decimal? DiscountValue { get; set; } + public List? SubItems { get; set; } + } + + public class CustomerSubItemDto + { + public Guid CustomerSubItemId { get; set; } + public string? SubItemName { get; set; } + public string? SubItemDescription { get; set; } + public decimal SubItemPrice { get; set; } + public string Frequency { get; set; } = "OneTime"; + public bool IsActive { get; set; } + public DateTime CreatedAt { get; set; } + } + + public class AddExtraFeeRequest + { + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public decimal Amount { get; set; } + public string Frequency { get; set; } = "OneTime"; // "OneTime" | "Recurring" + } +} diff --git a/DTOs/CustomerSubscription/SubscriptionSummaryDto.cs b/DTOs/CustomerSubscription/SubscriptionSummaryDto.cs new file mode 100644 index 0000000..90e79cd --- /dev/null +++ b/DTOs/CustomerSubscription/SubscriptionSummaryDto.cs @@ -0,0 +1,62 @@ +namespace Fitness_core_BE.DTOs.CustomerSubscription +{ + /// Full picture of one subscription: plan config, members, money, schedule. + public class SubscriptionSummaryDto + { + public Guid SubscriptionId { get; set; } + public Guid CustomerPlanId { get; set; } + public string? PlanName { get; set; } + public string PlanScope { get; set; } = "Individual"; + public int MemberLimit { get; set; } = 1; + public string DurationMode { get; set; } = "Rolling"; + public bool AllowSplitPayments { get; set; } + public decimal PlanPrice { get; set; } + public DateTime OnboardDate { get; set; } + public DateTime? ExpiryDate { get; set; } + public DateTime? RenewalDate { get; set; } + public bool IsActive { get; set; } + + public Guid? CustomerInvoiceId { get; set; } + public decimal InvoiceAmount { get; set; } + public decimal PaidAmount { get; set; } + public decimal Balance { get; set; } + public string PaymentStatus { get; set; } = "Pending"; + + public List Members { get; set; } = new(); + public List Installments { get; set; } = new(); + + public bool AutoChargeEnabled { get; set; } + public AutoChargeCardDto? AutoChargeCard { get; set; } + } + + public class AutoChargeCardDto + { + public Guid CardId { get; set; } + public string? CardLast4 { get; set; } + public string? CardType { get; set; } + } + + public class SubscriptionMemberDto + { + public Guid SubscriptionMemberId { get; set; } + public Guid CustomerId { get; set; } + public string? CusName { get; set; } + public string? Email { get; set; } + public string? PrimaryPH { get; set; } + public bool IsPrimary { get; set; } + public DateTime JoinedAt { get; set; } + public bool IsActive { get; set; } + /// False when unassigned from the plan but still a member of the gym. + public bool IsPlanAssigned { get; set; } = true; + } + + public class SubscriptionInstallmentDto + { + public Guid SubscriptionInstallmentId { get; set; } + public int InstallmentNo { get; set; } + public DateTime DueDate { get; set; } + public decimal Amount { get; set; } + public decimal PaidAmount { get; set; } + public string Status { get; set; } = "Pending"; + } +} diff --git a/DTOs/DocumentDownloadModel.cs b/DTOs/DocumentDownloadModel.cs new file mode 100644 index 0000000..3f8950b --- /dev/null +++ b/DTOs/DocumentDownloadModel.cs @@ -0,0 +1,9 @@ +namespace Fitness_core_BE.DTOs +{ + public class DocumentDownloadModel + { + public string FileName { get; set; } + public string FileType { get; set; } + public byte[] FileData { get; set; } + } +} diff --git a/DTOs/Facility/FacilityDtos.cs b/DTOs/Facility/FacilityDtos.cs new file mode 100644 index 0000000..5fa9162 --- /dev/null +++ b/DTOs/Facility/FacilityDtos.cs @@ -0,0 +1,25 @@ +namespace Fitness_core_BE.DTOs.Facility +{ + public class FacilityDto + { + public Guid FacilityId { get; set; } + public string Name { get; set; } = string.Empty; + public string FacilityType { get; set; } = string.Empty; + public string? Description { get; set; } + public int DefaultCapacity { get; set; } + public bool IsActive { get; set; } + public DateTime CreatedAt { get; set; } + } + + public class FacilitySessionDto + { + public Guid FacilitySessionId { get; set; } + public Guid FacilityId { get; set; } + public string? FacilityName { get; set; } + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public int Capacity { get; set; } + public int BookedCount { get; set; } + public bool IsCancelled { get; set; } + } +} diff --git a/DTOs/Facility/FacilityRequests.cs b/DTOs/Facility/FacilityRequests.cs new file mode 100644 index 0000000..4c77a1c --- /dev/null +++ b/DTOs/Facility/FacilityRequests.cs @@ -0,0 +1,51 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fitness_core_BE.DTOs.Facility +{ + public class CreateFacilityRequest + { + [Required] + public string Name { get; set; } = string.Empty; + + /// "Gym" | "Pool" | "Court" | "Class". + [Required] + public string FacilityType { get; set; } = "Class"; + + public string? Description { get; set; } + + [Range(1, int.MaxValue, ErrorMessage = "DefaultCapacity must be at least 1")] + public int DefaultCapacity { get; set; } = 1; + } + + public class UpdateFacilityRequest + { + [Required] + public Guid FacilityId { get; set; } + public string? Name { get; set; } + public string? FacilityType { get; set; } + public string? Description { get; set; } + public int? DefaultCapacity { get; set; } + public bool? IsActive { get; set; } + } + + public class SetFacilityStatusRequest + { + [Required] + public bool IsActive { get; set; } + } + + public class CreateFacilitySessionRequest + { + [Required] + public Guid FacilityId { get; set; } + + [Required] + public DateTime StartTime { get; set; } + + [Required] + public DateTime EndTime { get; set; } + + /// Overrides the facility's default capacity for this specific session. + public int? Capacity { get; set; } + } +} diff --git a/DTOs/Ledger/LedgerDtos.cs b/DTOs/Ledger/LedgerDtos.cs new file mode 100644 index 0000000..5e31445 --- /dev/null +++ b/DTOs/Ledger/LedgerDtos.cs @@ -0,0 +1,40 @@ +namespace Fitness_core_BE.DTOs.Ledger +{ + public class LedgerEntryDto + { + public Guid? Id { get; set; } // null for derived (non-manual) entries + public string EntryType { get; set; } = "Expense"; // "Income" | "Expense" + public string Category { get; set; } = string.Empty; + public string? Description { get; set; } + public decimal Amount { get; set; } + public DateTime EntryDate { get; set; } + public bool IsManual { get; set; } + } + + public class AddLedgerEntryRequest + { + public string EntryType { get; set; } = "Expense"; // "Income" | "Expense" + public string Category { get; set; } = "Other"; + public string? Description { get; set; } + public decimal Amount { get; set; } + public DateTime? EntryDate { get; set; } + } + + public class CategoryTotalDto + { + public string Category { get; set; } = string.Empty; + public decimal Amount { get; set; } + } + + public class ProfitLossDto + { + public DateTime From { get; set; } + public DateTime To { get; set; } + public decimal TotalIncome { get; set; } + public decimal TotalExpense { get; set; } + public decimal NetProfit { get; set; } + public List IncomeByCategory { get; set; } = new(); + public List ExpenseByCategory { get; set; } = new(); + public List Entries { get; set; } = new(); + } +} diff --git a/DTOs/Meal/CustomerMealDto.cs b/DTOs/Meal/CustomerMealDto.cs new file mode 100644 index 0000000..aa20872 --- /dev/null +++ b/DTOs/Meal/CustomerMealDto.cs @@ -0,0 +1,16 @@ +namespace Fitness_core_BE.DTOs.Meal +{ + public class CustomerMealDto + { + public Guid CustomerMealId { get; set; } + public Guid CustomerId { get; set; } + public string? CustomerName { get; set; } + public Guid MealPlanId { get; set; } + public string? MealPlanName { get; set; } + public bool IsActive { get; set; } + public bool IsFollowing { get; set; } + public DateTime AssignedDate { get; set; } + public DateTime? CompletedDate { get; set; } + public string? Notes { get; set; } + } +} diff --git a/DTOs/Meal/MealCategoryDto.cs b/DTOs/Meal/MealCategoryDto.cs new file mode 100644 index 0000000..eb6a351 --- /dev/null +++ b/DTOs/Meal/MealCategoryDto.cs @@ -0,0 +1,11 @@ +namespace Fitness_core_BE.DTOs.Meal +{ + public class MealCategoryDto + { + public Guid MealCategoryId { get; set; } + public string Name { get; set; } + public string? Description { get; set; } + public bool IsActive { get; set; } + public DateTime CreateDate { get; set; } + } +} diff --git a/DTOs/Meal/MealItemDto.cs b/DTOs/Meal/MealItemDto.cs new file mode 100644 index 0000000..cbb92f4 --- /dev/null +++ b/DTOs/Meal/MealItemDto.cs @@ -0,0 +1,19 @@ +namespace Fitness_core_BE.DTOs.Meal +{ + public class MealDto + { + public Guid MealId { get; set; } + public string Name { get; set; } + public string? Description { get; set; } + public decimal? Calories { get; set; } + public decimal? Protein { get; set; } + public decimal? Carbohydrates { get; set; } + public decimal? Fats { get; set; } + public string? ServingSize { get; set; } + public object? MealItems { get; set; } + public string? PreparationNotes { get; set; } + public Guid MealPlanId { get; set; } + public bool IsActive { get; set; } + public DateTime CreateDate { get; set; } + } +} diff --git a/DTOs/Meal/MealPlanDetailDto.cs b/DTOs/Meal/MealPlanDetailDto.cs new file mode 100644 index 0000000..01d985e --- /dev/null +++ b/DTOs/Meal/MealPlanDetailDto.cs @@ -0,0 +1,22 @@ +namespace Fitness_core_BE.DTOs.Meal +{ + public class MealPlanDetailDto + { + public Guid MealPlanId { get; set; } + public string Name { get; set; } + public string? Description { get; set; } + public bool IsTemplate { get; set; } + public Guid MealCategoryId { get; set; } + public string MealCategoryName { get; set; } + public Guid? ClientId { get; set; } + public string? ClientName { get; set; } + public bool IsActive { get; set; } + public DateTime CreateDate { get; set; } + public List MealItems { get; set; } = new List(); + public List AssignedCustomers { get; set; } = new List(); + public decimal? TotalCalories { get; set; } + public decimal? TotalProtein { get; set; } + public decimal? TotalCarbohydrates { get; set; } + public decimal? TotalFats { get; set; } + } +} diff --git a/DTOs/Meal/MealPlanDto.cs b/DTOs/Meal/MealPlanDto.cs new file mode 100644 index 0000000..5fe583e --- /dev/null +++ b/DTOs/Meal/MealPlanDto.cs @@ -0,0 +1,17 @@ +namespace Fitness_core_BE.DTOs.Meal +{ + public class MealPlanDto + { + public Guid MealPlanId { get; set; } + public string Name { get; set; } + public string? Description { get; set; } + public bool IsTemplate { get; set; } + public Guid MealCategoryId { get; set; } + public string MealCategoryName { get; set; } + public Guid? ClientId { get; set; } + public string? ClientName { get; set; } + public bool IsActive { get; set; } + public DateTime CreateDate { get; set; } + public List AssignedCustomers { get; set; } = new List(); + } +} diff --git a/DTOs/Mobile/AuthRequests.cs b/DTOs/Mobile/AuthRequests.cs new file mode 100644 index 0000000..cd3665f --- /dev/null +++ b/DTOs/Mobile/AuthRequests.cs @@ -0,0 +1,65 @@ +namespace Fitness_core_BE.DTOs.Mobile +{ + public class SendOtpRequest + { + public string Identifier { get; set; } = string.Empty; + public int NumberOfDigits { get; set; } = 4; + public bool NewUser { get; set; } + } + + public class VerifyOtpLoginRequest + { + public string OtpCode { get; set; } = string.Empty; + public string ReferenceNumber { get; set; } = string.Empty; + } + + public class VerifyOtpAltRequest + { + public string OtpCode { get; set; } = string.Empty; + public string ReferenceNumber { get; set; } = string.Empty; + public string? IdentifireType { get; set; } + public string? Identifier { get; set; } + } + + public class RefreshTokenRequest + { + public string? RefreshToken { get; set; } + } + + public class RegisterCustomerRequest + { + public string Gender { get; set; } = string.Empty; + public string Bod { get; set; } = string.Empty; + public string UserTypeId { get; set; } = string.Empty; + public string RoleId { get; set; } = string.Empty; + public bool ChkUser { get; set; } + public string? DeviceName { get; set; } + public string? PushTokenId { get; set; } + public string Fullname { get; set; } = string.Empty; + public string MobileNumber { get; set; } = string.Empty; + } + + public class ForgotPasswordRequest + { + public string Identifier { get; set; } = string.Empty; + public bool UseResetLink { get; set; } + public int NumberOfDigits { get; set; } = 6; + } + + public class ResetPasswordRequest + { + public string ReferenceNumber { get; set; } = string.Empty; + public string NewPassword { get; set; } = string.Empty; + public string ConfirmPassword { get; set; } = string.Empty; + } + + // Verifies an OTP for a customer's own channel and sets the matching verified flag. + public class VerifyChannelRequest + { + // "PrimaryPH" | "SecondaryPH" | "Email" + public string IdentifireType { get; set; } = string.Empty; + public string OtpCode { get; set; } = string.Empty; + public string ReferenceNumber { get; set; } = string.Empty; + public string Identifier { get; set; } = string.Empty; + } +} diff --git a/DTOs/Mobile/ChatRequests.cs b/DTOs/Mobile/ChatRequests.cs new file mode 100644 index 0000000..8b00569 --- /dev/null +++ b/DTOs/Mobile/ChatRequests.cs @@ -0,0 +1,15 @@ +namespace Fitness_core_BE.DTOs.Mobile +{ + public class GetOrCreateChatRequest + { + public Guid OtherUserId { get; set; } + } + + public class SendChatMessageRequest + { + public Guid ChatId { get; set; } + public Guid SenderId { get; set; } + public string Message { get; set; } = string.Empty; + public string Type { get; set; } = "text"; + } +} diff --git a/DTOs/Mobile/CustomerProfileRequests.cs b/DTOs/Mobile/CustomerProfileRequests.cs new file mode 100644 index 0000000..39f723a --- /dev/null +++ b/DTOs/Mobile/CustomerProfileRequests.cs @@ -0,0 +1,72 @@ +namespace Fitness_core_BE.DTOs.Mobile +{ + /// Self-service profile update (mobile). Distinct from DTOs/Customer/UpdateCustomerRequest, + /// which is the client-managed shape used by ClientController. + public class UpdateCustomerProfileRequest + { + public string? Fullname { get; set; } + public string? Gender { get; set; } + public DateOnly? Bod { get; set; } + public string? PrimaryPH { get; set; } + public string? SecondaryPH { get; set; } + public string? Nic { get; set; } + public string? Address { get; set; } + public string? Email { get; set; } + } + + public class UpdateHealthRequest + { + public string? BloodGroup { get; set; } + public string? Disabilities { get; set; } + } + + public class CreateProgressRequest + { + public decimal? Height { get; set; } + public decimal? CurrentWeight { get; set; } + public decimal? TargetWeight { get; set; } + public string? Goal { get; set; } + public int? Age { get; set; } + public string? Gender { get; set; } + public string? ActivityLevel { get; set; } + public decimal? TimePeriod { get; set; } + } + + /// Updates the customer's latest height and/or weight measurement. + public class UpdateProgressRequest + { + public decimal? Height { get; set; } + public decimal? Weight { get; set; } + public DateTime? ProgressDate { get; set; } + } + + /// Member self-serve "add card" for auto-deduct. cardToken is an opaque client-side + /// value (mock today, would be real PayHere/Stripe tokenization once a gateway is wired up). + public class SaveCardRequest + { + public string CardToken { get; set; } = string.Empty; + public string? CardLast4 { get; set; } + public string? CardType { get; set; } + public string? NickName { get; set; } + /// Must be explicitly true — this is the member's auto-deduct consent. + public bool Consent { get; set; } + } + + public class SetAutoChargeRequest + { + public bool Enabled { get; set; } + public Guid? CardId { get; set; } + } + + public class UpdatePushTokenRequest + { + public string PushTokenId { get; set; } = string.Empty; + } + + /// documentId comes from POST /customer/documents' upload response — the client + /// uploads the picked image first, then links it here as the profile picture. + public class UpdateProfileImageRequest + { + public int DocumentId { get; set; } + } +} diff --git a/DTOs/Mobile/GymOnboardingRequests.cs b/DTOs/Mobile/GymOnboardingRequests.cs new file mode 100644 index 0000000..8870c4b --- /dev/null +++ b/DTOs/Mobile/GymOnboardingRequests.cs @@ -0,0 +1,7 @@ +namespace Fitness_core_BE.DTOs.Mobile +{ + public class AcceptGymTermsRequest + { + public int Version { get; set; } + } +} diff --git a/DTOs/Mobile/TicketRequests.cs b/DTOs/Mobile/TicketRequests.cs new file mode 100644 index 0000000..2cbe9f5 --- /dev/null +++ b/DTOs/Mobile/TicketRequests.cs @@ -0,0 +1,20 @@ +namespace Fitness_core_BE.DTOs.Mobile +{ + public class CreateTicketRequest + { + public string MainReason { get; set; } = string.Empty; + public string? TicketCategoryId { get; set; } + public string? TicketStatus { get; set; } + } + + public class SendTicketMessageRequest + { + public string Message { get; set; } = string.Empty; + } + + public class SubmitTicketFeedbackRequest + { + public int Rating { get; set; } + public string? Feedback { get; set; } + } +} diff --git a/DTOs/PaymentGateways/PaymentGatewayClientDtos.cs b/DTOs/PaymentGateways/PaymentGatewayClientDtos.cs new file mode 100644 index 0000000..1a9c72f --- /dev/null +++ b/DTOs/PaymentGateways/PaymentGatewayClientDtos.cs @@ -0,0 +1,89 @@ +namespace Fitness_core_BE.DTOs.PaymentGateways +{ + // Mirrors Payment-Service's DTOs/Payments/*.cs for the HMAC-signed, per-platform + // Cards/Payments/Refunds surface (IPaymentGatewayClient) — distinct from + // PaymentGatewayDtos.cs, which is the X-Internal-Key admin/catalog surface. + + public class OnboardCardRequest + { + public string GatewayName { get; set; } = "DirectPay"; + public string CustomerId { get; set; } = string.Empty; + public string CardLabel { get; set; } = string.Empty; + public string CardholderName { get; set; } = string.Empty; + public bool IsDefault { get; set; } = false; + public string? ReturnUrl { get; set; } + } + + public class OnboardCardResult + { + public string Status { get; set; } = string.Empty; + public string? RedirectUrl { get; set; } + public string CardToken { get; set; } = string.Empty; + public string MaskedPan { get; set; } = string.Empty; + public string CardLabel { get; set; } = string.Empty; + public string CardBrand { get; set; } = string.Empty; + public int ExpiryMonth { get; set; } + public int ExpiryYear { get; set; } + public bool IsDefault { get; set; } + public string GatewayRef { get; set; } = string.Empty; + } + + public class CardDetailsResult + { + public string CardToken { get; set; } = string.Empty; + public string MaskedPan { get; set; } = string.Empty; + public string CardLabel { get; set; } = string.Empty; + public string CardBrand { get; set; } = string.Empty; + public int ExpiryMonth { get; set; } + public int ExpiryYear { get; set; } + public bool IsDefault { get; set; } + public string GatewayName { get; set; } = string.Empty; + } + + public class AttemptPaymentRequest + { + public string IdempotencyKey { get; set; } = string.Empty; + public string GatewayName { get; set; } = "DirectPay"; + public string CardToken { get; set; } = string.Empty; + public decimal AmountMinorUnits { get; set; } + public string Currency { get; set; } = "LKR"; + public string Description { get; set; } = string.Empty; + } + + public class AttemptPaymentResult + { + public Guid PaymentId { get; set; } + public string Status { get; set; } = string.Empty; + public string? GatewayRef { get; set; } + public DateTime CreatedAtUtc { get; set; } + } + + public class RefundPaymentResult + { + public Guid PaymentId { get; set; } + public string Status { get; set; } = string.Empty; + public string GatewayRef { get; set; } = string.Empty; + } + + public class SetDefaultCardResult + { + public string CardToken { get; set; } = string.Empty; + public bool IsDefault { get; set; } + } + + public class PaymentListItemResult + { + public Guid PaymentId { get; set; } + public decimal AmountMinorUnits { get; set; } + public string Currency { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public DateTime CreatedAtUtc { get; set; } + } + + public class PaymentListResult + { + public int Total { get; set; } + public int Page { get; set; } + public List Items { get; set; } = new(); + } +} diff --git a/DTOs/PaymentGateways/PaymentGatewayDtos.cs b/DTOs/PaymentGateways/PaymentGatewayDtos.cs new file mode 100644 index 0000000..33bab00 --- /dev/null +++ b/DTOs/PaymentGateways/PaymentGatewayDtos.cs @@ -0,0 +1,38 @@ +namespace Fitness_core_BE.DTOs.PaymentGateways +{ + // Mirrors Payment-Service's DTOs/Gateways/GatewayDtos.cs - Fitness_Core_BE's + // AdminController proxies to Payment-Service rather than exposing it directly + // to the admin frontend (see Phase 0 decision: no admin/JWT auth surface there). + public class GatewayDefinitionDto + { + public Guid Id { get; set; } + public string GatewayName { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public bool IsEnabled { get; set; } + public bool IsDefault { get; set; } + public string Mode { get; set; } = "Sandbox"; + public bool SupportsWebhooks { get; set; } + } + + public class UpsertGatewayDefinitionRequest + { + public string GatewayName { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public bool IsEnabled { get; set; } = true; + public bool IsDefault { get; set; } = false; + public string Mode { get; set; } = "Sandbox"; + public bool SupportsWebhooks { get; set; } = true; + } + + public class SetGatewayEnabledRequest + { + public bool IsEnabled { get; set; } + } + + public class UpsertPlatformGatewayCredentialRequest + { + public Guid PlatformId { get; set; } + public string GatewayName { get; set; } = string.Empty; + public string CredentialsJson { get; set; } = string.Empty; + } +} diff --git a/DTOs/PaymentsEnablement/PaymentsEnablementDtos.cs b/DTOs/PaymentsEnablement/PaymentsEnablementDtos.cs new file mode 100644 index 0000000..426bacf --- /dev/null +++ b/DTOs/PaymentsEnablement/PaymentsEnablementDtos.cs @@ -0,0 +1,37 @@ +namespace Fitness_core_BE.DTOs.PaymentsEnablement +{ + // Gym owner's own payout track selection, KYC submission, wallet and payout schedule — + // distinct from Fitness_Core_BE.DTOs.PaymentGateways, which is the platform-admin's + // gateway catalog (proxied to Payment-Service). + public record PaymentTrackOption(string Track, object FeeSummary); + public record SelectPaymentTrackRequest(string Track); + public record PaymentTrackSelectionResult(string Track, bool KycRequired); + + public record SubmitGatewayOnboardingRequest(Guid GatewayId); + + public record GatewayOnboardingStatusDto( + string KycStatus, + Guid? GatewayId, + string? GatewayName, + string? RejectionReason); + + public record ClientPayoutKycDto( + Guid Id, + Guid ClientId, + string? ClientName, + Guid? GatewayId, + string? GatewayName, + string KycStatus, + DateTime SubmittedAt, + DateTime? ReviewedAt, + string? RejectionReason, + string? NicNumber, + int? NicFrontDocId, + int? NicBackDocId); + + public record ReviewPayoutKycRequest(string Status, string? Reason); + + public record WalletDto(decimal Balance, string Currency, List Ledger, DateTime? LastPayoutDate); + public record WalletLedgerEntryDto(Guid Id, string EntryType, decimal Amount, string? Description, DateTime CreatedAt); + public record PayoutScheduleDto(DateTime NextPayoutDate, string Cadence, decimal PendingAmount); +} diff --git a/DTOs/Place/PlaceDtos.cs b/DTOs/Place/PlaceDtos.cs new file mode 100644 index 0000000..f82db33 --- /dev/null +++ b/DTOs/Place/PlaceDtos.cs @@ -0,0 +1,15 @@ +namespace Fitness_core_BE.DTOs.Place +{ + public class PlaceDto + { + public Guid PlaceId { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public bool IsActive { get; set; } + public DateTime CreatedAt { get; set; } + + // At most one for now -- see AccessDevice.FKPlaceId. + public Guid? DeviceId { get; set; } + public string? DeviceName { get; set; } + } +} diff --git a/DTOs/Place/PlaceRequests.cs b/DTOs/Place/PlaceRequests.cs new file mode 100644 index 0000000..f6ed978 --- /dev/null +++ b/DTOs/Place/PlaceRequests.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fitness_core_BE.DTOs.Place +{ + public class CreatePlaceRequest + { + [Required] + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + } + + public class UpdatePlaceRequest + { + [Required] + public Guid PlaceId { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public bool? IsActive { get; set; } + } + + public class SetPlaceStatusRequest + { + [Required] + public bool IsActive { get; set; } + } + + public class AssignPlaceDeviceRequest + { + // Null unassigns the place's current device. + public Guid? DeviceId { get; set; } + } +} diff --git a/DTOs/PlatformBilling/ClientPaymentMethodDtos.cs b/DTOs/PlatformBilling/ClientPaymentMethodDtos.cs new file mode 100644 index 0000000..6e64672 --- /dev/null +++ b/DTOs/PlatformBilling/ClientPaymentMethodDtos.cs @@ -0,0 +1,53 @@ +namespace Fitness_core_BE.DTOs.PlatformBilling +{ + // The gym's own saved card(s) for paying their HexaFitness platform subscription + // (ClientCard), and the transaction history behind those charges. Distinct from + // PaymentGatewayDtos.cs (admin gateway catalog) and PaymentsEnablementDtos.cs + // (payout track/KYC/wallet) — this is the "gym pays HexaFitness" card-charging surface. + + // Fetched live from Payment-Service on every call — CardToken is the identifier (not a + // local id), and none of these fields are ever cached in Core's own database. + public class ClientCardDto + { + public string CardToken { get; set; } = string.Empty; + public string MaskedPan { get; set; } = string.Empty; + public string CardBrand { get; set; } = string.Empty; + public int ExpiryMonth { get; set; } + public int ExpiryYear { get; set; } + public bool IsDefault { get; set; } + } + + public class StartCardOnboardingRequest + { + public string? CardLabel { get; set; } + public string? ReturnUrl { get; set; } + } + + public class StartCardOnboardingResult + { + public string RedirectUrl { get; set; } = string.Empty; + public string SessionId { get; set; } = string.Empty; + } + + public class CardOnboardingStatusResult + { + public bool IsComplete { get; set; } + public ClientCardDto? Card { get; set; } + } + + public class TransactionHistoryItemDto + { + public Guid PaymentId { get; set; } + public decimal AmountMinorUnits { get; set; } + public string Currency { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public DateTime CreatedAtUtc { get; set; } + } + + public class TransactionHistoryResult + { + public int Total { get; set; } + public int Page { get; set; } + public List Items { get; set; } = new(); + } +} diff --git a/DTOs/PlatformBilling/PlatformBillingDtos.cs b/DTOs/PlatformBilling/PlatformBillingDtos.cs new file mode 100644 index 0000000..8c6a3c5 --- /dev/null +++ b/DTOs/PlatformBilling/PlatformBillingDtos.cs @@ -0,0 +1,137 @@ +namespace Fitness_core_BE.DTOs.PlatformBilling +{ + public class PricingPlanDto + { + public Guid ClientPackageId { get; set; } + public string? PackageName { get; set; } + public string? PackageDescription { get; set; } + public decimal PerMemberRate { get; set; } + public decimal MinimumMonthlyBill { get; set; } + public decimal? MonthlyCap { get; set; } + public decimal SetupFee { get; set; } + public int IncludedMemberCount { get; set; } + public int? MaxBillableMemberCount { get; set; } + public decimal OwnGatewayFeePercent { get; set; } + public decimal OwnGatewayFeeFixed { get; set; } + public decimal OurGatewayFeePercent { get; set; } + public decimal OurGatewayFeeFixed { get; set; } + public int PayoutDayOfMonth { get; set; } + public bool IsDefault { get; set; } + public bool IsActive { get; set; } + } + + public class UpsertPricingPlanRequest + { + public Guid? ClientPackageId { get; set; } + public string PackageName { get; set; } = string.Empty; + public string? PackageDescription { get; set; } + public decimal PerMemberRate { get; set; } + public decimal MinimumMonthlyBill { get; set; } + public decimal? MonthlyCap { get; set; } + public decimal SetupFee { get; set; } + public int IncludedMemberCount { get; set; } + public int? MaxBillableMemberCount { get; set; } + public decimal OwnGatewayFeePercent { get; set; } + public decimal OwnGatewayFeeFixed { get; set; } + public decimal OurGatewayFeePercent { get; set; } + public decimal OurGatewayFeeFixed { get; set; } + public int PayoutDayOfMonth { get; set; } = 1; + public bool IsDefault { get; set; } + } + + public class DevicePricingDto + { + public decimal DeviceRentalFeePerMonth { get; set; } + public decimal DeviceInstallationFee { get; set; } + public decimal DeviceConfigureExistingFee { get; set; } + } + + public class AssignPricingPlanRequest + { + public Guid ClientPackageId { get; set; } + // Optional: decide the fingerprint-machine charge at the same time as the plan assignment. + public string? DeviceBillingMode { get; set; } // "FullPurchase" | "Rental" | null (no device charge) + public decimal? DeviceAgreedAmount { get; set; } + public decimal? DeviceSetupFee { get; set; } + } + + public class ClientDeviceBillingDto + { + public Guid Id { get; set; } + public string Mode { get; set; } = "Rental"; + public decimal AgreedAmount { get; set; } + public decimal SetupFee { get; set; } + public DateTime AssignedAt { get; set; } + } + + public class RecordClientInvoicePaymentRequest + { + public decimal Amount { get; set; } + } + + public class PlatformFeeBreakdownDto + { + public Guid ClientId { get; set; } + public string? PlanName { get; set; } + public int ActiveMemberCount { get; set; } + public decimal PerMemberRate { get; set; } + public decimal MinimumMonthlyBill { get; set; } + public decimal? MonthlyCap { get; set; } + public decimal BaseFee { get; set; } + public int DeviceCount { get; set; } + public decimal DeviceFee { get; set; } + public decimal GatewayFee { get; set; } + public decimal TotalFee { get; set; } + } + + public class ClientInvoiceItemDto + { + public string? ItemName { get; set; } + public string? ItemDescription { get; set; } + public decimal ItemPrice { get; set; } + } + + public class ClientInvoiceSummaryDto + { + public Guid ClientInvoiceId { get; set; } + public decimal Amount { get; set; } + public string Status { get; set; } = string.Empty; + public DateTime ClientInvoiceDate { get; set; } + public List Items { get; set; } = new(); + } + + public class PendingPlatformInvoiceDto + { + public Guid ClientInvoiceId { get; set; } + public Guid ClientId { get; set; } + public string? ClientName { get; set; } + public string? City { get; set; } + public decimal Amount { get; set; } + public decimal PaidAmount { get; set; } + public string Status { get; set; } = string.Empty; + public DateTime ClientInvoiceDate { get; set; } + public int DaysOverdue { get; set; } + } + + // Result of one worker run: RunAndChargeSubscriptionBillingAsync both generates any + // invoices newly due this period and drives them (and any still-in-flight from a prior + // run) toward Paid/Failed via Payment-Service. + public class SubscriptionChargeRunResult + { + public int InvoicesGenerated { get; set; } + public int ChargesAttempted { get; set; } + public int Paid { get; set; } + public int StillProcessing { get; set; } + public int Failed { get; set; } + public int SkippedNoCard { get; set; } + // A prior attempt already came back Failed — Payment-Service payments can never leave + // Failed, so these are left Pending for the existing manual pay-link fallback rather + // than retried automatically here. + public int SkippedPreviouslyFailed { get; set; } + // The invoice's balance changed (e.g. an admin recorded a manual partial payment) since + // an in-flight charge was created for the old balance — retrying would either overcharge + // or hit Payment-Service's idempotency-conflict check, so this is left for manual review. + public int SkippedAmountMismatch { get; set; } + public int Errors { get; set; } + } +} diff --git a/DTOs/Rbac/RbacDtos.cs b/DTOs/Rbac/RbacDtos.cs new file mode 100644 index 0000000..ba41669 --- /dev/null +++ b/DTOs/Rbac/RbacDtos.cs @@ -0,0 +1,47 @@ +namespace Fitness_core_BE.DTOs.Rbac +{ + public class NavItemDto + { + public string Key { get; set; } = string.Empty; + public string Label { get; set; } = string.Empty; + public string? ShortLabel { get; set; } + public string Href { get; set; } = string.Empty; + public string Icon { get; set; } = string.Empty; + } + + public class NavResponseDto + { + public bool IsOwner { get; set; } + public List Items { get; set; } = new(); + } + + public class GymRoleDto + { + public Guid GymRoleId { get; set; } + public string Name { get; set; } = string.Empty; + public bool IsActive { get; set; } + public List NavKeys { get; set; } = new(); + } + + public class CreateGymRoleRequest + { + public string Name { get; set; } = string.Empty; + } + + public class UpdateGymRoleRequest + { + public string? Name { get; set; } + public bool? IsActive { get; set; } + } + + public class SetRolePermissionsRequest + { + public List NavKeys { get; set; } = new(); + } + + public class AssignStaffRoleRequest + { + // Nullable to support unassigning a staff member's role. + public Guid? GymRoleId { get; set; } + } +} diff --git a/DTOs/Staff/CreateStaffRequest.cs b/DTOs/Staff/CreateStaffRequest.cs new file mode 100644 index 0000000..0cc1311 --- /dev/null +++ b/DTOs/Staff/CreateStaffRequest.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fitness_core_BE.DTOs.Staff +{ + public class CreateStaffRequest + { + [Required] + public Guid ClientId { get; set; } + + [Required] + public string UserName { get; set; } = string.Empty; + + [Required] + [EmailAddress] + public string Email { get; set; } = string.Empty; + + [Required] + [Phone] + public string PhoneNumber { get; set; } = string.Empty; + + public string? StaffPosition { get; set; } + + public string? Department { get; set; } + + public DateTime? HireDate { get; set; } + } +} diff --git a/DTOs/Staff/StaffDto.cs b/DTOs/Staff/StaffDto.cs new file mode 100644 index 0000000..6f265b4 --- /dev/null +++ b/DTOs/Staff/StaffDto.cs @@ -0,0 +1,27 @@ +namespace Fitness_core_BE.DTOs.Staff +{ + public class StaffDto + { + public Guid StaffId { get; set; } + public Guid UserId { get; set; } + public string? UserName { get; set; } + public string? Email { get; set; } + public string? PhoneNumber { get; set; } + public Guid ClientId { get; set; } + public string? ClientName { get; set; } + public Guid? GymRoleId { get; set; } + public string? GymRoleName { get; set; } + public string? StaffPosition { get; set; } + public string? Department { get; set; } + public DateTime HireDate { get; set; } + public bool IsActive { get; set; } + public DateTime CreatedAt { get; set; } + public long? AccessProfileId { get; set; } + public string? AccessNumber { get; set; } + public string AccessProvisionStatus { get; set; } = "NotConfigured"; + public string ActualAccessState { get; set; } = "Unknown"; + public string DesiredAccessState { get; set; } = "Enabled"; + public int FingerprintCount { get; set; } + public string? AccessLastError { get; set; } + } +} diff --git a/DTOs/Staff/StaffPayrollDtos.cs b/DTOs/Staff/StaffPayrollDtos.cs new file mode 100644 index 0000000..1ff24c0 --- /dev/null +++ b/DTOs/Staff/StaffPayrollDtos.cs @@ -0,0 +1,30 @@ +namespace Fitness_core_BE.DTOs.Staff +{ + public class StaffPayRateDto + { + public Guid Id { get; set; } + public string RateType { get; set; } = "Monthly"; + public decimal Amount { get; set; } + public DateTime EffectiveFrom { get; set; } + } + + public class UpsertStaffPayRateRequest + { + public string RateType { get; set; } = "Monthly"; // "Monthly" | "Hourly" + public decimal Amount { get; set; } + } + + public class StaffPaymentDto + { + public Guid Id { get; set; } + public decimal Amount { get; set; } + public string? Note { get; set; } + public DateTime PaidAt { get; set; } + } + + public class RecordStaffPaymentRequest + { + public decimal Amount { get; set; } + public string? Note { get; set; } + } +} diff --git a/DTOs/Staff/UpdateStaffRequest.cs b/DTOs/Staff/UpdateStaffRequest.cs new file mode 100644 index 0000000..617be5a --- /dev/null +++ b/DTOs/Staff/UpdateStaffRequest.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fitness_core_BE.DTOs.Staff +{ + public class UpdateStaffRequest + { + [Required] + public Guid StaffId { get; set; } + + public string? UserName { get; set; } + + [EmailAddress] + public string? Email { get; set; } + + [Phone] + public string? PhoneNumber { get; set; } + + public string? StaffPosition { get; set; } + + public string? Department { get; set; } + + public bool? IsActive { get; set; } + } +} diff --git a/DTOs/Stats/ClientStatSummaryDto.cs b/DTOs/Stats/ClientStatSummaryDto.cs new file mode 100644 index 0000000..412258c --- /dev/null +++ b/DTOs/Stats/ClientStatSummaryDto.cs @@ -0,0 +1,21 @@ +namespace Fitness_core_BE.DTOs.Stats +{ + public class ClientStatSummaryDto + { + public int TotalCustomerCount { get; set; } + public int ThisMonthCustomerCount { get; set; } + public int LastMonthCustomerCount { get; set; } + public decimal CustomerGrowthPercentage { get; set; } + public decimal ThisMonthRevenue { get; set; } + public decimal LastMonthRevenue { get; set; } + public decimal RevenueGrowthPercentage { get; set; } + public decimal TotalRevenue { get; set; } + public int OverdueCount { get; set; } + public int ExpiringWithin7DaysCount { get; set; } + public int TodayPaymentsCount { get; set; } + public decimal TodayPaymentsTotal { get; set; } + // Muscle Beast-only pilot feature (their explicit request) -- gated server-side so the + // frontend never needs to know any gym's ClientId to decide whether to show it. + public bool ShowTodayPayments { get; set; } + } +} diff --git a/DTOs/Stats/MonthlyRevenueStat.cs b/DTOs/Stats/MonthlyRevenueStat.cs new file mode 100644 index 0000000..ada9242 --- /dev/null +++ b/DTOs/Stats/MonthlyRevenueStat.cs @@ -0,0 +1,11 @@ +namespace Fitness_core_BE.DTOs.Stats +{ + public class MonthlyRevenueStat + { + public int Year { get; set; } + public int Month { get; set; } + public string MonthLabel { get; set; } + public decimal Revenue { get; set; } + public int NewMemberCount { get; set; } + } +} diff --git a/DTOs/Ticket/TicketDetailDto.cs b/DTOs/Ticket/TicketDetailDto.cs new file mode 100644 index 0000000..69412f5 --- /dev/null +++ b/DTOs/Ticket/TicketDetailDto.cs @@ -0,0 +1,48 @@ +using Fitness_core_BE.Models.Enums; + +namespace Fitness_core_BE.DTOs.Ticket +{ + public class TicketDetailDto + { + public Guid TicketId { get; set; } + public string MainReason { get; set; } + public DateTime CreateDate { get; set; } + + // User Information + public Guid UserId { get; set; } + public string UserName { get; set; } + public string UserEmail { get; set; } + public string UserPhoneNumber { get; set; } + + // Admin Information (nullable) + public Guid? AdminId { get; set; } + public string? AdminName { get; set; } + public string? AdminPhoneNumber { get; set; } + + // Category Information + public Guid? TicketCategoryId { get; set; } + public string? TicketCategoryName { get; set; } + public PriorityLevel? PriorityLevel { get; set; } + + // Status Information + public TicketStatusEnum TicketStatus { get; set; } + + // Feedback Information + public int? UserRating { get; set; } + public string? UserFeedback { get; set; } + public DateTime? FeedbackAt { get; set; } + + // Messages + public List Messages { get; set; } = new List(); + } + + public class TicketMessageDto + { + public Guid MessageId { get; set; } + public bool IsSentByAdmin { get; set; } + public string Message { get; set; } + public string? AttachmentURL { get; set; } + public DateTime CreateDate { get; set; } + public int? FKDocumentId { get; set; } + } +} diff --git a/DTOs/Ticket/TicketDto.cs b/DTOs/Ticket/TicketDto.cs new file mode 100644 index 0000000..0abc69c --- /dev/null +++ b/DTOs/Ticket/TicketDto.cs @@ -0,0 +1,31 @@ +using Fitness_core_BE.Models.Enums; + +namespace Fitness_core_BE.DTOs.Ticket +{ + public class TicketDto + { + public Guid TicketId { get; set; } + public string MainReason { get; set; } + public DateTime CreateDate { get; set; } + + // User Information + public Guid UserId { get; set; } + public string UserName { get; set; } + public string UserEmail { get; set; } + + // Admin Information (nullable) + public Guid? AdminId { get; set; } + public string? AdminName { get; set; } + + // Category Information + public Guid? TicketCategoryId { get; set; } + public string? TicketCategoryName { get; set; } + public PriorityLevel? PriorityLevel { get; set; } + + // Rating + public int? UserRating { get; set; } + + // Status Information + public TicketStatusEnum TicketStatus { get; set; } + } +} diff --git a/DTOs/TimeBankCard/TimeBankCardDto.cs b/DTOs/TimeBankCard/TimeBankCardDto.cs new file mode 100644 index 0000000..1018f2e --- /dev/null +++ b/DTOs/TimeBankCard/TimeBankCardDto.cs @@ -0,0 +1,24 @@ +namespace Fitness_core_BE.DTOs.TimeBankCard +{ + public class TimeBankCardDto + { + public Guid TimeBankCardId { get; set; } + public string RfidTagId { get; set; } = string.Empty; + public Guid? CustomerId { get; set; } + public int TotalMinutesPurchased { get; set; } + public int MinutesRemaining { get; set; } + public decimal AmountPaid { get; set; } + public DateTime PurchasedAt { get; set; } + public DateTime? ExpiresAt { get; set; } + public bool IsActive { get; set; } + + /// True right after a tap-in swipe (session open, clock running); false after + /// tap-out or when the card has no open session. + public bool SessionOpen { get; set; } + public DateTime? SessionStartedAt { get; set; } + public string? SessionFacilityName { get; set; } + + /// Only populated in the response to a tap-out swipe. + public int? MinutesDeductedThisSwipe { get; set; } + } +} diff --git a/DTOs/TimeBankCard/TimeBankCardRequests.cs b/DTOs/TimeBankCard/TimeBankCardRequests.cs new file mode 100644 index 0000000..f500fa4 --- /dev/null +++ b/DTOs/TimeBankCard/TimeBankCardRequests.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fitness_core_BE.DTOs.TimeBankCard +{ + public class IssueTimeBankCardRequest + { + [Required] + public string RfidTagId { get; set; } = string.Empty; + + [Required] + [Range(1, int.MaxValue, ErrorMessage = "TotalMinutesPurchased must be at least 1")] + public int TotalMinutesPurchased { get; set; } + + [Range(0, double.MaxValue, ErrorMessage = "AmountPaid must be zero or greater")] + public decimal AmountPaid { get; set; } + + /// Ties the card to a member's own account; omit for a walk-in-only card. + public Guid? CustomerId { get; set; } + + public DateTime? ExpiresAt { get; set; } + } + + // No MinutesToDeduct -- the swipe itself is tap-in (first swipe, starts the clock) or + // tap-out (second swipe, deducts the elapsed time). See TimeBankCard.ActiveSessionStartedAt. + public class SwipeTimeBankCardRequest + { + [Required] + public string RfidTagId { get; set; } = string.Empty; + + [Required] + public Guid FacilityId { get; set; } + } +} diff --git a/DTOs/Tute/UpdateTuteItemRequest.cs b/DTOs/Tute/UpdateTuteItemRequest.cs new file mode 100644 index 0000000..77adaff --- /dev/null +++ b/DTOs/Tute/UpdateTuteItemRequest.cs @@ -0,0 +1,9 @@ +namespace Fitness_core_BE.DTOs.Tute +{ + public class UpdateTuteItemRequest + { + public int Id { get; set; } + public string Title { get; set; } = string.Empty; + public string? Notes { get; set; } + } +} diff --git a/DTOs/Workout/CustomerWorkoutDto.cs b/DTOs/Workout/CustomerWorkoutDto.cs new file mode 100644 index 0000000..ada1d88 --- /dev/null +++ b/DTOs/Workout/CustomerWorkoutDto.cs @@ -0,0 +1,16 @@ +namespace Fitness_core_BE.DTOs.Workout +{ + public class CustomerWorkoutDto + { + public Guid CustomerWorkoutId { get; set; } + public Guid CustomerId { get; set; } + public string? CustomerName { get; set; } + public Guid WorkoutPlanId { get; set; } + public string? WorkoutPlanName { get; set; } + public bool IsActive { get; set; } + public bool IsFollowing { get; set; } + public DateTime AssignedDate { get; set; } + public DateTime? CompletedDate { get; set; } + public string? Notes { get; set; } + } +} diff --git a/DTOs/Workout/WorkoutCategoryDto.cs b/DTOs/Workout/WorkoutCategoryDto.cs new file mode 100644 index 0000000..d005f31 --- /dev/null +++ b/DTOs/Workout/WorkoutCategoryDto.cs @@ -0,0 +1,11 @@ +namespace Fitness_core_BE.DTOs.Workout +{ + public class WorkoutCategoryDto + { + public Guid WorkoutCategoryId { get; set; } + public string Name { get; set; } + public string? Description { get; set; } + public bool IsActive { get; set; } + public DateTime CreateDate { get; set; } + } +} diff --git a/DTOs/Workout/WorkoutExerciseDto.cs b/DTOs/Workout/WorkoutExerciseDto.cs new file mode 100644 index 0000000..44498b1 --- /dev/null +++ b/DTOs/Workout/WorkoutExerciseDto.cs @@ -0,0 +1,17 @@ +namespace Fitness_core_BE.DTOs.Workout +{ + public class WorkoutExerciseDto + { + public Guid WorkoutExerciseId { get; set; } + public string Name { get; set; } = string.Empty; + public string? MuscleGroup { get; set; } + public int? Sets { get; set; } + public string? Reps { get; set; } + public string? Description { get; set; } + public string? BreathIn { get; set; } + public string? BreathOut { get; set; } + public string? VideoUrl { get; set; } + public int SortOrder { get; set; } + public bool IsCompleted { get; set; } + } +} diff --git a/DTOs/Workout/WorkoutItemDto.cs b/DTOs/Workout/WorkoutItemDto.cs new file mode 100644 index 0000000..9c410a0 --- /dev/null +++ b/DTOs/Workout/WorkoutItemDto.cs @@ -0,0 +1,23 @@ +namespace Fitness_core_BE.DTOs.Workout +{ + public class WorkoutItemDto + { + public Guid WorkoutItemId { get; set; } + public string ExerciseDay { get; set; } = string.Empty; + public string? Description { get; set; } + public string? Exercises { get; set; } + public string? HowToDo { get; set; } + public string? DoURL { get; set; } + public string? Duration { get; set; } + public string? WormUp { get; set; } + public string? Cooldown { get; set; } + public Guid WorkoutPlanId { get; set; } + public bool IsActive { get; set; } + public DateTime CreateDate { get; set; } + public DateTime? UpdatedAt { get; set; } + public List WorkoutExercises { get; set; } = new List(); + public int TotalExercises { get; set; } + public int CompletedExercises { get; set; } + public bool IsCompleted { get; set; } + } +} diff --git a/DTOs/Workout/WorkoutPlanDetailDto.cs b/DTOs/Workout/WorkoutPlanDetailDto.cs new file mode 100644 index 0000000..51e9034 --- /dev/null +++ b/DTOs/Workout/WorkoutPlanDetailDto.cs @@ -0,0 +1,18 @@ +namespace Fitness_core_BE.DTOs.Workout +{ + public class WorkoutPlanDetailDto + { + public Guid WorkoutPlanId { get; set; } + public string Name { get; set; } + public string? Description { get; set; } + public bool IsTemplate { get; set; } + public Guid WorkoutCategoryId { get; set; } + public string WorkoutCategoryName { get; set; } + public Guid? ClientId { get; set; } + public string? ClientName { get; set; } + public bool IsActive { get; set; } + public DateTime CreateDate { get; set; } + public List WorkoutItems { get; set; } = new List(); + public List AssignedCustomers { get; set; } = new List(); + } +} diff --git a/DTOs/Workout/WorkoutPlanDto.cs b/DTOs/Workout/WorkoutPlanDto.cs new file mode 100644 index 0000000..c3f1a86 --- /dev/null +++ b/DTOs/Workout/WorkoutPlanDto.cs @@ -0,0 +1,17 @@ +namespace Fitness_core_BE.DTOs.Workout +{ + public class WorkoutPlanDto + { + public Guid WorkoutPlanId { get; set; } + public string Name { get; set; } + public string? Description { get; set; } + public bool IsTemplate { get; set; } + public Guid WorkoutCategoryId { get; set; } + public string WorkoutCategoryName { get; set; } + public Guid? ClientId { get; set; } + public string? ClientName { get; set; } + public bool IsActive { get; set; } + public DateTime CreateDate { get; set; } + public List AssignedCustomers { get; set; } = new List(); + } +} diff --git a/Data/WorkoutPlanSeedData.cs b/Data/WorkoutPlanSeedData.cs new file mode 100644 index 0000000..87df7b9 --- /dev/null +++ b/Data/WorkoutPlanSeedData.cs @@ -0,0 +1,308 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Data +{ + /// + /// Fixed, deterministic seed data for the 3 platform-wide "coach guided" workout plans + /// (1-day / 2-day / 3-day splits) inserted via EF Core migration HasData. GUIDs are + /// hardcoded (not Guid.NewGuid()) because HasData requires stable keys across migrations. + /// + public static class WorkoutPlanSeedData + { + public static readonly Guid CategoryId = Guid.Parse("6df7ddee-1923-43ff-b349-d10beaab5531"); + + public static readonly Guid Plan1DayId = Guid.Parse("dc845372-fd61-4ec6-ad5f-4b61b569ea83"); + public static readonly Guid Plan2DayId = Guid.Parse("abca6a2c-3b33-460e-9384-7c29dec89887"); + public static readonly Guid Plan3DayId = Guid.Parse("712adef6-7915-4e6a-9e60-2a077b3f33a9"); + + public static readonly Guid Plan1Day1ItemId = Guid.Parse("ee06a6c4-cbb0-41d8-956f-04940ebb3ad4"); + public static readonly Guid Plan2Day1ItemId = Guid.Parse("f0886ca4-2f72-4abd-8307-2891f026e8f9"); + public static readonly Guid Plan2Day2ItemId = Guid.Parse("ed586263-03ea-4f0c-a538-c069d061dc1c"); + public static readonly Guid Plan3Day1ItemId = Guid.Parse("66ebcbba-8de5-4cb6-af0e-e24d371d79a0"); + public static readonly Guid Plan3Day2ItemId = Guid.Parse("27c39410-1c57-4137-8737-a59706d6443e"); + public static readonly Guid Plan3Day3ItemId = Guid.Parse("f988a8a9-8384-47c7-b815-2e92eef5bd7e"); + + private static readonly DateTime SeedDate = new DateTime(2026, 8, 2, 0, 0, 0, DateTimeKind.Utc); + + private static string VideoSearchUrl(string exerciseName) => + "https://www.youtube.com/results?search_query=" + Uri.EscapeDataString(exerciseName + " exercise tutorial"); + + public static WorkoutCategory[] Categories() => new[] + { + new WorkoutCategory + { + WorkoutCategoryId = CategoryId, + WorkoutCategoryName = "Coach Guided Plans", + Description = "Structured plans curated by HexaFitness so members can train confidently without a live coach.", + IsActive = true, + CreateDate = SeedDate + } + }; + + public static WorkoutPlan[] Plans() => new[] + { + new WorkoutPlan + { + WorkoutPlanId = Plan1DayId, + WorkoutPlanName = "1-Day Full Body Plan", + Description = "A single full-body session hitting every major muscle group. Best for members who can train once or twice a week.", + IsTemplate = true, + FKWorkoutCategoryId = CategoryId, + FKClientId = null, + IsActive = true, + CreateDate = SeedDate + }, + new WorkoutPlan + { + WorkoutPlanId = Plan2DayId, + WorkoutPlanName = "2-Day Upper/Lower Split", + Description = "Alternate an upper-body day with a lower-body-and-core day. Great for two or four sessions a week.", + IsTemplate = true, + FKWorkoutCategoryId = CategoryId, + FKClientId = null, + IsActive = true, + CreateDate = SeedDate + }, + new WorkoutPlan + { + WorkoutPlanId = Plan3DayId, + WorkoutPlanName = "3-Day Push/Pull/Legs Split", + Description = "Classic push, pull, legs rotation for members training three to six days a week.", + IsTemplate = true, + FKWorkoutCategoryId = CategoryId, + FKClientId = null, + IsActive = true, + CreateDate = SeedDate + } + }; + + public static WorkoutItem[] Items() => new[] + { + new WorkoutItem + { + WorkoutItemId = Plan1Day1ItemId, + ExerciseDay = "Day 1 - Full Body", + Description = "Full body strength session covering chest, back, shoulders, arms, legs and core.", + FKWorkoutPlanId = Plan1DayId, + Duration = "45-55 minutes", + WormUp = "5-8 minutes of light cardio plus dynamic stretches for hips, shoulders and ankles.", + Cooldown = "5 minutes of static stretching for the muscles trained.", + IsActive = true, + CreateDate = SeedDate, + UpdatedAt = SeedDate + }, + new WorkoutItem + { + WorkoutItemId = Plan2Day1ItemId, + ExerciseDay = "Day 1 - Upper Body", + Description = "Chest, back, shoulders, biceps and triceps.", + FKWorkoutPlanId = Plan2DayId, + Duration = "50-60 minutes", + WormUp = "5 minutes light cardio plus arm circles and band pull-aparts.", + Cooldown = "5 minutes stretching chest, back and shoulders.", + IsActive = true, + CreateDate = SeedDate, + UpdatedAt = SeedDate + }, + new WorkoutItem + { + WorkoutItemId = Plan2Day2ItemId, + ExerciseDay = "Day 2 - Lower Body & Core", + Description = "Quads, hamstrings, glutes, calves and core.", + FKWorkoutPlanId = Plan2DayId, + Duration = "50-60 minutes", + WormUp = "5-8 minutes light cardio plus bodyweight squats and leg swings.", + Cooldown = "5 minutes stretching quads, hamstrings and calves.", + IsActive = true, + CreateDate = SeedDate, + UpdatedAt = SeedDate + }, + new WorkoutItem + { + WorkoutItemId = Plan3Day1ItemId, + ExerciseDay = "Day 1 - Push", + Description = "Chest, shoulders and triceps.", + FKWorkoutPlanId = Plan3DayId, + Duration = "50-60 minutes", + WormUp = "5 minutes light cardio plus arm circles and shoulder dislocates.", + Cooldown = "5 minutes stretching chest, shoulders and triceps.", + IsActive = true, + CreateDate = SeedDate, + UpdatedAt = SeedDate + }, + new WorkoutItem + { + WorkoutItemId = Plan3Day2ItemId, + ExerciseDay = "Day 2 - Pull", + Description = "Back and biceps.", + FKWorkoutPlanId = Plan3DayId, + Duration = "50-60 minutes", + WormUp = "5 minutes light cardio plus band pull-aparts and scapular retractions.", + Cooldown = "5 minutes stretching back and biceps.", + IsActive = true, + CreateDate = SeedDate, + UpdatedAt = SeedDate + }, + new WorkoutItem + { + WorkoutItemId = Plan3Day3ItemId, + ExerciseDay = "Day 3 - Legs & Core", + Description = "Quads, hamstrings, glutes, calves and core.", + FKWorkoutPlanId = Plan3DayId, + Duration = "55-65 minutes", + WormUp = "8 minutes light cardio plus bodyweight squats, lunges and leg swings.", + Cooldown = "5-8 minutes stretching the full lower body.", + IsActive = true, + CreateDate = SeedDate, + UpdatedAt = SeedDate + } + }; + + private record Ex( + Guid Id, Guid ItemId, int Order, string Name, string MuscleGroup, + int Sets, string Reps, string Description, string BreathIn, string BreathOut); + + public static WorkoutExercise[] Exercises() + { + var defs = new[] + { + // ---- Plan 1: 1-Day Full Body ---- + new Ex(Guid.Parse("d9c8f4e6-d889-46ea-8231-a0003239eac3"), Plan1Day1ItemId, 1, "Bodyweight Squat", "Legs", 3, "15", + "Stand with feet shoulder-width apart and lower your hips back and down as if sitting into a chair, then drive back up.", + "Inhale as you bend your knees and lower into the squat.", "Exhale as you drive through your heels and stand back up."), + new Ex(Guid.Parse("9a655052-29d7-4e31-8cd9-7f29724fe9a3"), Plan1Day1ItemId, 2, "Push-Up", "Chest", 3, "12", + "Hands under shoulders, body in a straight line from head to heels. Lower your chest to the floor and press back up.", + "Inhale as you lower your chest toward the floor.", "Exhale as you press back up to the start."), + new Ex(Guid.Parse("d5f5e067-927e-419c-8878-d6de902877a1"), Plan1Day1ItemId, 3, "Bent-Over Dumbbell Row", "Back", 3, "12", + "Hinge at the hips with a flat back, let the dumbbells hang, then pull them toward your waist squeezing your shoulder blades together.", + "Inhale as you let the dumbbells lower and arms extend.", "Exhale as you pull the dumbbells toward your torso."), + new Ex(Guid.Parse("27410090-3846-4bf1-b033-6a2f682c5e61"), Plan1Day1ItemId, 4, "Dumbbell Shoulder Press", "Shoulders", 3, "10", + "Sit or stand with a dumbbell in each hand at shoulder height, press overhead until arms are extended, then lower with control.", + "Inhale as you lower the dumbbells to shoulder height.", "Exhale as you press the dumbbells overhead."), + new Ex(Guid.Parse("1a4060e8-b98b-449c-98a4-e44aa895741e"), Plan1Day1ItemId, 5, "Dumbbell Bicep Curl", "Biceps", 3, "12", + "Elbows tucked at your sides, curl the dumbbells up toward your shoulders, then lower slowly.", + "Inhale as you lower the dumbbells back down.", "Exhale as you curl the dumbbells up toward your shoulders."), + new Ex(Guid.Parse("adba4556-0f84-4033-be42-ad6d714cbbc0"), Plan1Day1ItemId, 6, "Triceps Dip", "Triceps", 3, "10", + "Using a bench or chair, lower your body by bending your elbows to about 90 degrees, then press back up.", + "Inhale as you bend your elbows and lower your body.", "Exhale as you press back up to straighten your arms."), + new Ex(Guid.Parse("21fcc291-5124-4d32-94a7-dcbfc7f5d42d"), Plan1Day1ItemId, 7, "Walking Lunge", "Legs", 3, "12 each leg", + "Step forward into a lunge, lowering your back knee toward the floor, then push off to bring the back leg through into the next step.", + "Inhale as you step forward and lower your back knee.", "Exhale as you push back up to standing."), + new Ex(Guid.Parse("8803ff2c-a78a-419d-9e64-2166be5d53ac"), Plan1Day1ItemId, 8, "Plank", "Core", 3, "30-45 sec hold", + "Forearms and toes on the floor, body in a straight line, core braced. Hold the position without letting your hips sag.", + "Inhale slowly through the nose while holding the position.", "Exhale slowly through the mouth, keeping your core braced."), + + // ---- Plan 2, Day 1: Upper Body ---- + new Ex(Guid.Parse("a153ed45-d300-4f5a-8277-4c0a3868fcb9"), Plan2Day1ItemId, 1, "Flat Bench Press", "Chest", 4, "10", + "Lie on a flat bench, lower the bar to your mid-chest with control, then press it back up to full arm extension.", + "Inhale as you lower the bar to your chest.", "Exhale as you press the bar back up."), + new Ex(Guid.Parse("e0f19862-fd5e-4b0c-bd31-2ad184c15cc3"), Plan2Day1ItemId, 2, "Lat Pulldown", "Back", 4, "10", + "Grip the bar wide, pull it down to your upper chest while keeping your chest up, then let it rise back with control.", + "Inhale as you let the bar rise and your arms extend.", "Exhale as you pull the bar down toward your chest."), + new Ex(Guid.Parse("9cd3ca5c-b0b1-487b-b340-9120e5ad8a74"), Plan2Day1ItemId, 3, "Seated Dumbbell Shoulder Press", "Shoulders", 3, "12", + "Seated with back supported, press the dumbbells from shoulder height straight overhead, then lower with control.", + "Inhale as you lower the dumbbells to shoulder height.", "Exhale as you press the dumbbells overhead."), + new Ex(Guid.Parse("a85e98d5-b36a-47e1-b463-3c04d505667b"), Plan2Day1ItemId, 4, "Barbell Bicep Curl", "Biceps", 3, "12", + "Grip the bar shoulder-width, curl it up toward your chest keeping elbows fixed at your sides, then lower slowly.", + "Inhale as you lower the bar back down.", "Exhale as you curl the bar up toward your shoulders."), + new Ex(Guid.Parse("29c2321d-5841-4704-9d35-f937d0b1ad6c"), Plan2Day1ItemId, 5, "Triceps Rope Pushdown", "Triceps", 3, "12", + "Elbows pinned at your sides, push the rope down until your arms are fully extended, then let it rise back with control.", + "Inhale as you let the rope rise and your elbows bend.", "Exhale as you push the rope down to full extension."), + new Ex(Guid.Parse("dacb1386-1e57-44d2-aecd-21350b2ad5be"), Plan2Day1ItemId, 6, "Face Pull", "Shoulders", 3, "15", + "Using a rope on a cable machine at head height, pull the rope toward your face, flaring your elbows out wide.", + "Inhale as you extend your arms forward.", "Exhale as you pull the rope toward your face."), + + // ---- Plan 2, Day 2: Lower Body & Core ---- + new Ex(Guid.Parse("b46f76d9-0eef-41bd-9202-bbee8035dff4"), Plan2Day2ItemId, 1, "Barbell Back Squat", "Legs", 4, "10", + "Bar across your upper back, feet shoulder-width apart. Squat down until thighs are parallel to the floor, then drive back up.", + "Inhale as you bend your knees and lower into the squat.", "Exhale as you drive through your heels and stand back up."), + new Ex(Guid.Parse("3e3d0a10-63c1-4c68-ac5b-67c005f92fcc"), Plan2Day2ItemId, 2, "Romanian Deadlift", "Legs", 4, "10", + "Holding a barbell or dumbbells, hinge at the hips and lower the weight along your legs, keeping a flat back, then drive your hips forward to stand.", + "Inhale as you hinge and lower the weight down.", "Exhale as you drive your hips forward to stand tall."), + new Ex(Guid.Parse("9900c46f-3f74-46eb-8782-84ba5a6d5479"), Plan2Day2ItemId, 3, "Leg Press", "Legs", 3, "12", + "Feet shoulder-width on the platform, lower it by bending your knees toward your chest, then press it away without locking your knees.", + "Inhale as you bend your knees toward your chest.", "Exhale as you press the platform away."), + new Ex(Guid.Parse("639a1181-70e2-4cb3-966f-ba8089b8166c"), Plan2Day2ItemId, 4, "Standing Calf Raise", "Legs", 3, "15", + "Standing on the balls of your feet on a raised edge, lower your heels below the step, then rise up onto your toes.", + "Inhale as you lower your heels down.", "Exhale as you rise up onto your toes."), + new Ex(Guid.Parse("a7a43b3e-3cdb-4de4-ade7-16e9b229249a"), Plan2Day2ItemId, 5, "Hanging Leg Raise", "Core", 3, "12", + "Hang from a bar with straight arms, raise your legs up until roughly parallel to the floor, then lower with control.", + "Inhale as you lower your legs back down.", "Exhale as you raise your legs up."), + new Ex(Guid.Parse("6fedc4b4-5ba1-4c55-9783-7c64676f11e6"), Plan2Day2ItemId, 6, "Russian Twist", "Core", 3, "20 total", + "Sit with knees bent and torso leaned back slightly, rotate your torso to tap the floor on each side.", + "Inhale to reset at the centre.", "Exhale as you rotate to each side."), + + // ---- Plan 3, Day 1: Push ---- + new Ex(Guid.Parse("418d75b4-72cf-4736-91a0-0a208d225011"), Plan3Day1ItemId, 1, "Barbell Bench Press", "Chest", 4, "8", + "Lie on a flat bench, lower the bar to your mid-chest with control, then press it back up to full arm extension.", + "Inhale as you lower the bar to your chest.", "Exhale as you press the bar back up."), + new Ex(Guid.Parse("8eebef00-c567-4e55-ad4d-a2242e98ffdd"), Plan3Day1ItemId, 2, "Incline Dumbbell Press", "Chest", 3, "10", + "On an incline bench, press the dumbbells up from chest height until arms are extended, then lower with control.", + "Inhale as you lower the dumbbells to your chest.", "Exhale as you press the dumbbells back up."), + new Ex(Guid.Parse("8a2acd7a-e923-4861-ad37-1cd10923695f"), Plan3Day1ItemId, 3, "Overhead Barbell Press", "Shoulders", 3, "10", + "Bar at shoulder height, press it straight overhead until arms are extended, then lower back to shoulder height.", + "Inhale as you lower the bar to shoulder height.", "Exhale as you press the bar overhead."), + new Ex(Guid.Parse("acafc07f-cb25-44a3-8543-b503c8af0fc9"), Plan3Day1ItemId, 4, "Lateral Raise", "Shoulders", 3, "15", + "Holding a light dumbbell in each hand, raise your arms out to the sides until shoulder height, then lower with control.", + "Inhale as you lower your arms back down.", "Exhale as you raise your arms out to the sides."), + new Ex(Guid.Parse("98bd09c3-37f9-48c2-af12-fa44ed36b597"), Plan3Day1ItemId, 5, "Triceps Overhead Extension", "Triceps", 3, "12", + "Holding a dumbbell overhead with both hands, lower it behind your head by bending your elbows, then extend back up.", + "Inhale as you bend your elbows and lower the weight.", "Exhale as you extend your arms back overhead."), + + // ---- Plan 3, Day 2: Pull ---- + new Ex(Guid.Parse("1d04f884-0d01-48e1-8297-f088c4b1174a"), Plan3Day2ItemId, 1, "Deadlift", "Back", 4, "6", + "Feet hip-width, grip the bar just outside your legs, brace your core and drive through your heels to stand tall, keeping the bar close to your body.", + "Inhale and brace your core before lifting.", "Exhale as you drive your hips forward to stand tall."), + new Ex(Guid.Parse("6ea3b6bc-f8c4-4582-91f5-6a4a9d57ab19"), Plan3Day2ItemId, 2, "Pull-Up", "Back", 4, "8", + "Grip the bar shoulder-width, pull your chest toward the bar until your chin clears it, then lower with control.", + "Inhale as you lower yourself back down.", "Exhale as you pull yourself up toward the bar."), + new Ex(Guid.Parse("1ab08ef9-1936-4c31-b94f-8b86b535eed5"), Plan3Day2ItemId, 3, "Seated Cable Row", "Back", 3, "12", + "Seated with knees slightly bent, pull the handle toward your waist while keeping your back straight, then extend back out.", + "Inhale as you let the handle extend back out.", "Exhale as you pull the handle toward your waist."), + new Ex(Guid.Parse("55d052dd-8fff-448c-b1f2-6f021cd6c2a3"), Plan3Day2ItemId, 4, "Barbell Curl", "Biceps", 3, "12", + "Grip the bar shoulder-width, curl it up toward your chest keeping elbows fixed at your sides, then lower slowly.", + "Inhale as you lower the bar back down.", "Exhale as you curl the bar up toward your shoulders."), + new Ex(Guid.Parse("b15bae89-7bb4-4803-99cd-487839882473"), Plan3Day2ItemId, 5, "Hammer Curl", "Biceps", 3, "12", + "Holding dumbbells with palms facing your body, curl them up toward your shoulders keeping your wrists neutral, then lower slowly.", + "Inhale as you lower the dumbbells back down.", "Exhale as you curl the dumbbells up toward your shoulders."), + + // ---- Plan 3, Day 3: Legs & Core ---- + new Ex(Guid.Parse("2fea8666-58f7-4ccb-bf95-68b62c02d7c2"), Plan3Day3ItemId, 1, "Barbell Back Squat", "Legs", 4, "8", + "Bar across your upper back, feet shoulder-width apart. Squat down until thighs are parallel to the floor, then drive back up.", + "Inhale as you bend your knees and lower into the squat.", "Exhale as you drive through your heels and stand back up."), + new Ex(Guid.Parse("04d6fa32-70df-49b9-8a6e-09d35673e102"), Plan3Day3ItemId, 2, "Walking Lunge", "Legs", 3, "12 each leg", + "Step forward into a lunge, lowering your back knee toward the floor, then push off to bring the back leg through into the next step.", + "Inhale as you step forward and lower your back knee.", "Exhale as you push back up to standing."), + new Ex(Guid.Parse("f57d0779-5341-42e6-80d4-71b84f348e01"), Plan3Day3ItemId, 3, "Leg Curl", "Legs", 3, "12", + "Lying or seated on the machine, curl your heels toward your glutes against the resistance, then extend back with control.", + "Inhale as you extend your legs back down.", "Exhale as you curl your heels toward your glutes."), + new Ex(Guid.Parse("701b6dec-ab9f-43dc-abcb-8a6774c0ec35"), Plan3Day3ItemId, 4, "Standing Calf Raise", "Legs", 4, "15", + "Standing on the balls of your feet on a raised edge, lower your heels below the step, then rise up onto your toes.", + "Inhale as you lower your heels down.", "Exhale as you rise up onto your toes."), + new Ex(Guid.Parse("bfa52ab2-add0-42a4-ac1c-294b86d96879"), Plan3Day3ItemId, 5, "Plank", "Core", 3, "45 sec hold", + "Forearms and toes on the floor, body in a straight line, core braced. Hold the position without letting your hips sag.", + "Inhale slowly through the nose while holding the position.", "Exhale slowly through the mouth, keeping your core braced."), + new Ex(Guid.Parse("a91fdef1-6351-4c68-9115-f3bff738d971"), Plan3Day3ItemId, 6, "Bicycle Crunch", "Core", 3, "20 total", + "Lying on your back, alternate bringing elbow to opposite knee while extending the other leg out.", + "Inhale to reset at the centre.", "Exhale as you crunch elbow toward the opposite knee.") + }; + + return Array.ConvertAll(defs, d => new WorkoutExercise + { + WorkoutExerciseId = d.Id, + FKWorkoutItemId = d.ItemId, + SortOrder = d.Order, + Name = d.Name, + MuscleGroup = d.MuscleGroup, + Sets = d.Sets, + Reps = d.Reps, + Description = d.Description, + BreathIn = d.BreathIn, + BreathOut = d.BreathOut, + VideoUrl = VideoSearchUrl(d.Name), + IsActive = true, + CreateDate = SeedDate + }); + } + } +} diff --git a/Domain/CustomerSubscriptionFactory.cs b/Domain/CustomerSubscriptionFactory.cs new file mode 100644 index 0000000..2c218a9 --- /dev/null +++ b/Domain/CustomerSubscriptionFactory.cs @@ -0,0 +1,146 @@ +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Domain +{ + public sealed record CustomerSubscriptionAggregate( + CustomerSubscription Subscription, + CustomerSubItem InitialSubItem, + SubscriptionMember PrimaryMember, + IReadOnlyList Installments); + + public static class CustomerSubscriptionFactory + { + public static CustomerSubscriptionAggregate Create( + CustomerSubscription subscription, + CustomerPlan plan, + CustomerSubItem initialSubItem, + DateTime? nowUtc = null) + { + var now = nowUtc ?? DateTime.UtcNow; + subscription.SubscriptionId = + subscription.SubscriptionId == Guid.Empty + ? Guid.NewGuid() + : subscription.SubscriptionId; + subscription.CreatedAt = now; + subscription.OnboardDate = + subscription.OnboardDate == default ? now : subscription.OnboardDate; + + if (plan.DurationMode == "FixedRange") + { + if (plan.ValidTo == null || plan.ValidFrom == null) + throw new BadRequestException("Fixed-range plan is missing its validity dates."); + if (now > plan.ValidTo.Value) + throw new BadRequestException("This plan's validity period has already ended."); + + if (subscription.OnboardDate < plan.ValidFrom.Value) + subscription.OnboardDate = plan.ValidFrom.Value; + subscription.ExpiryDate ??= plan.ValidTo.Value; + } + else if (subscription.ExpiryDate == null && plan.DurationInDays > 0) + { + subscription.ExpiryDate = + subscription.OnboardDate.AddDays(plan.DurationInDays); + } + + // RenewalDate starts equal to ExpiryDate and is the only one of the two that ever + // advances again (on each approved renewal cycle) -- ExpiryDate is frozen from here on. + subscription.RenewalDate ??= subscription.ExpiryDate; + + initialSubItem.CustomerSubItemId = + initialSubItem.CustomerSubItemId == Guid.Empty + ? Guid.NewGuid() + : initialSubItem.CustomerSubItemId; + initialSubItem.FKCustomerSubscriptionId = subscription.SubscriptionId; + initialSubItem.CreatedAt = now; + + var primaryMember = new SubscriptionMember + { + SubscriptionMemberId = Guid.NewGuid(), + FKSubscriptionId = subscription.SubscriptionId, + FKCustomerId = subscription.FKCustomerId, + IsPrimary = true, + JoinedAt = now, + IsActive = true + }; + + return new CustomerSubscriptionAggregate( + subscription, + initialSubItem, + primaryMember, + BuildInstallmentSchedule(subscription, plan, now)); + } + + // Internal (not private): reused by SubscriptionRenewalApprovalService to build a fresh + // installment schedule for a renewal cycle on a subscription that already exists (no new + // CustomerSubscription row involved, unlike Create above). + internal static IReadOnlyList BuildInstallmentSchedule( + CustomerSubscription subscription, + CustomerPlan plan, + DateTime now, + DateTime? anchorDate = null) + { + // Defaults to subscription.OnboardDate, exactly Create()'s existing behavior (a + // brand-new subscription's OnboardDate is always "now" for that cycle). The + // renewal-approval flow passes the approval moment explicitly instead: unlike the old + // renewal path, the same CustomerSubscription row now persists across many cycles, so + // OnboardDate stays frozen at the very first cycle and can no longer anchor a later + // cycle's due dates on its own. + var scheduleAnchor = anchorDate ?? subscription.OnboardDate; + + var count = Math.Max(1, plan.InstallmentCount); + var onboardingFee = plan.HasOnboardingFee ? plan.OnboardingFeeAmount : 0m; + + // Mirrors CreateCustomerInvoiceFromSubscription's discount base (plan price + + // onboarding fee) so the installment schedule shown to the client (and used to + // prefill "record payment") matches what the invoice actually charges. Only the + // plan-price portion absorbs the reduction -- discountAmount=0 when no discount was + // given reproduces today's undiscounted split exactly. + var discountAmount = DiscountCalculator.ComputeDiscountAmount( + plan.PlanPrice + onboardingFee, subscription.DiscountType, subscription.DiscountValue); + var discountedPlanPrice = Math.Max(0m, plan.PlanPrice - discountAmount); + + var perInstallment = Math.Round(discountedPlanPrice / count, 2); + var rows = new List(count); + decimal allocated = 0m; + + double? fixedRangeStepDays = null; + if (plan.DurationMode == "FixedRange" && + plan.ValidTo != null && + count > 1) + { + var windowDays = + (plan.ValidTo.Value - scheduleAnchor).TotalDays; + if (windowDays > 0) + fixedRangeStepDays = windowDays / count; + } + + for (var index = 0; index < count; index++) + { + var membershipAmount = + index == count - 1 + ? discountedPlanPrice - allocated + : perInstallment; + allocated += membershipAmount; + + rows.Add(new SubscriptionInstallment + { + SubscriptionInstallmentId = Guid.NewGuid(), + FKSubscriptionId = subscription.SubscriptionId, + InstallmentNo = index + 1, + DueDate = fixedRangeStepDays.HasValue + ? scheduleAnchor.AddDays( + fixedRangeStepDays.Value * index) + : scheduleAnchor.AddMonths(index), + Amount = membershipAmount + (index == 0 ? onboardingFee : 0m), + PaidAmount = 0m, + Status = "Pending", + IsActive = true, + CreatedAt = now + }); + } + + return rows; + } + } +} diff --git a/Domain/DiscountCalculator.cs b/Domain/DiscountCalculator.cs new file mode 100644 index 0000000..d1d7e5b --- /dev/null +++ b/Domain/DiscountCalculator.cs @@ -0,0 +1,19 @@ +namespace Fitness_core_BE.Domain +{ + public static class DiscountCalculator + { + public static decimal ComputeDiscountAmount(decimal subtotal, string? discountType, decimal? discountValue) + { + if (string.IsNullOrEmpty(discountType) || discountValue is not decimal value || value <= 0 || subtotal <= 0) + return 0m; + + var discount = discountType switch + { + "Percentage" => subtotal * Math.Clamp(value, 0m, 100m) / 100m, + "Flat" => Math.Clamp(value, 0m, subtotal), + _ => 0m + }; + return Math.Round(discount, 2); + } + } +} diff --git a/Exceptions/CustomExceptions.cs b/Exceptions/CustomExceptions.cs new file mode 100644 index 0000000..1ea7fec --- /dev/null +++ b/Exceptions/CustomExceptions.cs @@ -0,0 +1,28 @@ +namespace Fitness_core_BE.Exceptions +{ + public class NotFoundException : Exception + { + public NotFoundException(string message) : base(message) { } + } + + public class BadRequestException : Exception + { + public BadRequestException(string message) : base(message) { } + } + + public class UnauthorizedException : Exception + { + public UnauthorizedException(string message) : base(message) { } + } + + public class ForbiddenException : Exception + { + public ForbiddenException(string message) : base(message) { } + } + + public class ServiceUnavailableException : Exception + { + public ServiceUnavailableException(string message) : base(message) { } + public ServiceUnavailableException(string message, Exception innerException) : base(message, innerException) { } + } +} diff --git a/Fitness-core-BE.sln b/Fitness-core-BE.sln new file mode 100644 index 0000000..94fa4e9 --- /dev/null +++ b/Fitness-core-BE.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 18 +VisualStudioVersion = 18.1.11312.151 d18.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fitness_core_BE", "Fitness_core_BE.csproj", "{684FAD5E-63E2-8926-CC9B-6B4EEBE2C339}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {684FAD5E-63E2-8926-CC9B-6B4EEBE2C339}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {684FAD5E-63E2-8926-CC9B-6B4EEBE2C339}.Debug|Any CPU.Build.0 = Debug|Any CPU + {684FAD5E-63E2-8926-CC9B-6B4EEBE2C339}.Release|Any CPU.ActiveCfg = Release|Any CPU + {684FAD5E-63E2-8926-CC9B-6B4EEBE2C339}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {20DDB187-F4A5-407D-BECC-043EF293BEC1} + EndGlobalSection +EndGlobal diff --git a/Fitness_Core_API.postman_collection.json b/Fitness_Core_API.postman_collection.json new file mode 100644 index 0000000..4e3a545 --- /dev/null +++ b/Fitness_Core_API.postman_collection.json @@ -0,0 +1,4278 @@ +{ + "info": { + "_postman_id": "fitness-core-api-collection", + "name": "Fitness Core Backend API", + "description": "API for managing fitness-related operations including ticket management system", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "Health Check", + "item": [ + { + "name": "Get API Status", + "id": "1fdf322a-b0f9-4a6b-bc8a-224442e4948e", + "request": { + "method": "GET", + "header": [ + + ], + "url": { + "raw": "{{baseUrl}}/status", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "status" + ] + }, + "description": "Check API health status" + }, + "response": [ + + ] + } + ], + "id": "d804167a-7546-4495-bfa7-d3b1e34cfa1d" + }, + { + "name": "Ticket Management", + "item": [ + { + "name": "Get Ticket List", + "id": "029fa8c9-7bbb-4bc0-b2b8-d7a4c6ecf11d", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetTicketList\",\n \"Payload\": {},\n \"Reference\": \"optional-reference-id-12345\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/ticket", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "ticket" + ] + }, + "description": "Retrieve all active tickets with related data (User and Admin)" + }, + "response": [ + + ] + }, + { + "name": "Get Ticket By User ID", + "id": "4060720f-6aa2-4794-8b9e-942f0f88a30e", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetTicketByUserId\",\n \"Payload\": {},\n \"Reference\": \"optional-reference-id-67890\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/ticket", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "ticket" + ] + }, + "description": "Retrieve all tickets for the authenticated user. Currently using test UserId: a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d. Includes messages." + }, + "response": [ + + ] + }, + { + "name": "Get Ticket By ID", + "id": "bd0b7a50-6852-469e-a16e-6a3d8de624df", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetTicketById\",\n \"Payload\": {\n \"ticketId\": \"{{ticketId}}\"\n },\n \"Reference\": \"get-ticket-by-id-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/ticket", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "ticket" + ] + }, + "description": "Retrieve a specific ticket by its ID. Includes User, Admin, and Messages." + }, + "response": [ + + ] + }, + { + "name": "Get Ticket Category List", + "id": "c5c6a9c4-6890-4c95-8ff6-3ddb0e2282f3", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetTicketCategoryList\",\n \"Payload\": {},\n \"Reference\": \"optional-reference-id-abc123\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/ticket", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "ticket" + ] + }, + "description": "Retrieve all active ticket categories ordered by name" + }, + "response": [ + + ] + }, + { + "name": "Create Ticket", + "id": "b43dc94a-26a8-4be8-abb8-da9da8b72a8a", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"CreateTicket\",\n \"Payload\": {\n \"mainReason\": \"payment need to be postponed\",\n \"ticketCategoryId\": \"{{ticketCategoryId}}\",\n \"ticketStatus\": \"Open\"\n },\n \"Reference\": \"create-ticket-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/ticket", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "ticket" + ] + }, + "description": "Create a new ticket. Currently using test UserId: a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d. ticketCategoryId and ticketStatus are optional. Valid ticketStatus values: Pending (default), Open, Closed." + }, + "response": [ + + ] + }, + { + "name": "Send Message", + "id": "75586fa2-9c59-4742-82b3-289baae14669", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"SendMessage\",\n \"Payload\": {\n \"message\": \"I am unable to complete my payment. Please help.\",\n \"ticketId\": \"{{ticketId}}\"\n },\n \"Reference\": \"send-message-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/ticket", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "ticket" + ] + }, + "description": "Send a message to a ticket. Automatically detects if sender is an admin and sets IsSentByAdmin flag. Currently using test UserId: a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d. attachmentURL and documentId are optional." + }, + "response": [ + + ] + }, + { + "name": "Close Ticket", + "id": "ed4fce1c-da9f-4dac-a341-a1df07e03cc0", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"CloseTicket\",\n \"Payload\": {\n \"ticketId\": \"{{ticketId}}\"\n },\n \"Reference\": \"close-ticket-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/ticket", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "ticket" + ] + }, + "description": "Close a ticket by updating its status to Closed. Automatically sets the status to Closed enum value." + }, + "response": [ + + ] + } + ], + "id": "a32a2ec5-177e-4177-9d09-8a3348adec5d" + }, + { + "name": "Activity Log", + "item": [ + { + "name": "Log Activity", + "id": "e20245df-2521-4172-a7d5-4f2cbfa27a1e", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"LogActivity\",\n \"Payload\": {\n \"description\": \"User created a new ticket\"\n },\n \"Reference\": \"log-activity-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/activitylog", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "activitylog" + ] + }, + "description": "Log an activity. Gets userId from HttpContext, checks if user is admin, and saves log with userId, username, IsAdmin, description, and timestamp." + }, + "response": [ + + ] + }, + { + "name": "Get Activity Logs", + "id": "5aa817d6-ea5f-4b44-96a3-1bdb065902e1", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetActivityLogs\",\n \"Payload\": {\n \"searchTerm\": \"\",\n \"startDate\": \"\",\n \"endDate\": \"\"\n },\n \"Reference\": \"get-activity-logs-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/activitylog", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "activitylog" + ] + }, + "description": "Get activity logs with optional filters: searchTerm (searches description and username), startDate, and endDate. All filters are optional." + }, + "response": [ + + ] + } + ], + "id": "b9c7ecce-fc81-4bee-b562-34dd50cc337a" + }, + { + "name": "Client Management", + "item": [ + { + "name": "Get Client List", + "id": "c1a2b3c4-d5e6-4f7a-8b9c-0d1e2f3a4b5c", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetClientList\",\n \"Payload\": {},\n \"Reference\": \"get-client-list-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/client", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "client" + ] + }, + "description": "Retrieve all clients with their associated user details." + }, + "response": [ + + ] + }, + { + "name": "Get Client By Id", + "id": "b2c3d4e5-f6a7-4b8c-9d0e-0f1a2b3c4d5e", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetClientById\",\n \"Payload\": {\n \"clientId\": \"{{clientId}}\"\n },\n \"Reference\": \"get-client-by-id-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/client", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "client" + ] + }, + "description": "Retrieve a specific client by their ID, including user details." + }, + "response": [ + + ] + }, + { + "name": "Get Customer Details By ID", + "id": "b2c3d4e5-f6a7-4b8c-9d0e-0f1a2b3c4d5f", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetCustomerDetailsById\",\n \"Payload\": {\n \"customerId\": \"{{customerId}}\"\n },\n \"Reference\": \"get-customer-details-by-id-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/client", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "client" + ] + }, + "description": "Retrieve complete customer details by customer ID, including customer profile, user information, all meal plans with their meal items (including nutritional totals), and all workout plans with their workout items (including exercises). This endpoint combines data from customer, meal, and workout services into a single comprehensive response." + }, + "response": [ + + ] + }, + { + "name": "Create Customer With Plans", + "id": "d1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5e", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"CreateCustomerWithPlans\",\n \"Payload\": {\n \"fullname\": \"Joshua Smith\",\n \"mobileNumber\": \"+1234567890\",\n \"email\": \"joshua@example.com\",\n \"pushTokenId\": \"push-token-id\",\n \"gender\": \"Male\",\n \"bod\": \"1995-06-15\",\n \"userTypeId\": \"{{userTypeId}}\",\n \"roleId\": \"{{roleId}}\",\n \"workoutPlanIds\": [\"{{workoutPlanId}}\"],\n \"mealPlanIds\": [\"{{mealPlanId}}\"],\n \"customerPlanId\": \"{{customerPlanId}}\",\n \"expiryDate\": \"2026-12-31T00:00:00Z\"\n },\n \"Reference\": \"create-customer-with-plans-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/client", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "client" + ] + }, + "description": "Create a new customer with optional plan assignments (used by clients from their portal). Creates a Customer record + User, registers auth, then assigns plans. Required: fullname, mobileNumber, pushTokenId, gender, bod (date of birth, e.g. 1995-06-15), userTypeId, roleId. Optional: email, workoutPlanIds (array), mealPlanIds (array), customerPlanId, expiryDate. Returns customer + auth response." + }, + "response": [ + + ] + }, + { + "name": "Register Client", + "id": "d2b3c4d5-e6f7-4a8b-9c0d-1e2f3a4b5c6d", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"RegisterClient\",\n \"Payload\": {\n \"ClientName\": \"FitZone Gym\",\n \"ClientPhone\": \"+1234567890\",\n \"ClientEmail\": \"contact@fitzone.com\",\n \"BusinessType\": \"gym\",\n \"BusinessName\": \"FitZone Fitness Center\",\n \"BusinessLogoId\": \"LOGO001\",\n \"Description\": \"Premier fitness center\",\n \"IsPysicalLocation\": true,\n \"Address\": \"123 Main Street\",\n \"City\": \"New York\",\n \"facilities\": \"Cardio,Weights,Pool,Sauna\"\n },\n \"Reference\": \"register-client-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/client", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "client" + ] + }, + "description": "Register a new client with business details. Requires UserId in HttpContext. All business fields are optional except BusinessType." + }, + "response": [ + + ] + }, + { + "name": "Get Package List", + "id": "e3c4d5e6-f7a8-4b9c-0d1e-2f3a4b5c6d7e", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetPackageList\",\n \"Payload\": {},\n \"Reference\": \"get-package-list-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/client", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "client" + ] + }, + "description": "Retrieve all active client packages ordered by name." + }, + "response": [ + + ] + }, + { + "name": "Get Customer Plans By Client ID", + "id": "e5d6e7f8-a9b0-4c1d-2e3f-4a5b6c7d8e0b", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetCustomerPlansByClientId\",\n \"Payload\": {},\n \"Reference\": \"get-customer-plans-by-client-id-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/client", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "client" + ] + }, + "description": "Retrieve all active customer plans belonging to the authenticated client. Client ID is resolved from the authentication context (token/cookie) — no payload fields required. Returns plans ordered by creation date (newest first)." + }, + "response": [ + + ] + }, + { + "name": "Get Customers By Client ID", + "id": "e4d5e6f7-a8b9-4c0d-1e2f-3a4b5c6d7e9a", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetCustomersByClientId\",\n \"Payload\": {},\n \"Reference\": \"get-customers-by-client-id-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/client", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "client" + ] + }, + "description": "Retrieve all customers under the authenticated client. Client ID is resolved from the authentication context (token/cookie) — no payload fields required. Returns each unique customer with their latest active subscription and plan details. Deduplicates by customer, keeping only the most recent active subscription per customer." + }, + "response": [ + + ] + } + ], + "id": "f4d5e6f7-a8b9-4c0d-1e2f-3a4b5c6d7e8f" + }, + { + "name": "Client Invoice Management", + "item": [ + { + "name": "Create Invoice from Subscription", + "id": "a5b6c7d8-e9f0-4a1b-2c3d-4e5f6a7b8c9d", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"CreateClientInvoiceFromSubscription\",\n \"Payload\": {\n \"subscriptionId\": \"{{subscriptionId}}\"\n },\n \"Reference\": \"create-invoice-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/clientinvoice", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clientinvoice" + ] + }, + "description": "Create an invoice from a subscription. Automatically calculates total from plan price and all active sub-items. Sets status to \u0027Pending\u0027 by default." + }, + "response": [ + + ] + }, + { + "name": "Get Invoice by ID", + "id": "b6c7d8e9-f0a1-4b2c-3d4e-5f6a7b8c9d0e", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetClientInvoiceById\",\n \"Payload\": {\n \"invoiceId\": \"{{invoiceId}}\"\n },\n \"Reference\": \"get-invoice-by-id-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/clientinvoice", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clientinvoice" + ] + }, + "description": "Retrieve a specific invoice by its ID. Includes subscription, client, user, and card details." + }, + "response": [ + + ] + }, + { + "name": "Get Invoices by Client ID", + "id": "c7d8e9f0-a1b2-4c3d-4e5f-6a7b8c9d0e1f", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetClientInvoicesByClientId\",\n \"Payload\": {\n \"clientId\": \"{{clientId}}\"\n },\n \"Reference\": \"get-invoices-by-client-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/clientinvoice", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clientinvoice" + ] + }, + "description": "Retrieve all invoices for a specific client, ordered by invoice date (most recent first)." + }, + "response": [ + + ] + }, + { + "name": "Get Invoices by User ID", + "id": "d8e9f0a1-b2c3-4d4e-5f6a-7b8c9d0e1f2a", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetClientInvoicesByUserId\",\n \"Payload\": {\n \"userId\": \"{{userId}}\"\n },\n \"Reference\": \"get-invoices-by-user-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/clientinvoice", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clientinvoice" + ] + }, + "description": "Retrieve all invoices for a specific user, ordered by invoice date (most recent first)." + }, + "response": [ + + ] + }, + { + "name": "Update Invoice Status", + "id": "e9f0a1b2-c3d4-4e5f-6a7b-8c9d0e1f2a3b", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"UpdateClientInvoiceStatus\",\n \"Payload\": {\n \"invoiceId\": \"{{invoiceId}}\",\n \"status\": \"Paid\"\n },\n \"Reference\": \"update-invoice-status-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/clientinvoice", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clientinvoice" + ] + }, + "description": "Update invoice status. Valid statuses: Pending, Paid, Failed, Cancelled, Refunded." + }, + "response": [ + + ] + }, + { + "name": "Delete Invoice", + "id": "f0a1b2c3-d4e5-4f6a-7b8c-9d0e1f2a3b4c", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"DeleteClientInvoice\",\n \"Payload\": {\n \"invoiceId\": \"{{invoiceId}}\"\n },\n \"Reference\": \"delete-invoice-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/invoice", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "invoice" + ] + }, + "description": "Delete an invoice and all its related invoice items." + }, + "response": [ + + ] + }, + { + "name": "Calculate Invoice Total", + "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"CalculateClientInvoiceTotal\",\n \"Payload\": {\n \"invoiceId\": \"{{invoiceId}}\"\n },\n \"Reference\": \"calculate-invoice-total-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/invoice", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "invoice" + ] + }, + "description": "Calculate and return the total amount for an invoice." + }, + "response": [ + + ] + } + ], + "id": "a0b1c2d3-e4f5-4a6b-7c8d-9e0f1a2b3c4d" + }, + { + "name": "Customer Management", + "item": [ + { + "name": "Get All Customers", + "id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetAllCustomers\",\n \"Payload\": {},\n \"Reference\": \"get-all-customers-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/customer", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "customer" + ] + }, + "description": "Retrieve all customers with their associated user details." + }, + "response": [ + + ] + }, + { + "name": "Get Customer by ID", + "id": "c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetCustomerById\",\n \"Payload\": {\n \"customerId\": \"{{customerId}}\"\n },\n \"Reference\": \"get-customer-by-id-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/customer", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "customer" + ] + }, + "description": "Retrieve a specific customer by their ID, including user details." + }, + "response": [ + + ] + }, + { + "name": "Get Customer Meal Plan", + "id": "cust-get-mealplan-001", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetCustomerMealPlan\",\n \"Payload\": {},\n \"Reference\": \"get-customer-mealplan-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/customer", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "customer" + ] + }, + "description": "Retrieve the active meal plan for the authenticated customer. UserId is taken from HttpContext (cookie or token). Returns the customer\u0027s active meal plan, or a message when none exists." + }, + "response": [ + + ] + }, + { + "name": "Get Customer WorkOut Plan", + "id": "cust-get-workoutplan-001", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetCustomerWorkOutPlan\",\n \"Payload\": {},\n \"Reference\": \"get-customer-workoutplan-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/customer", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "customer" + ] + }, + "description": "Retrieve the active workout plan for the authenticated customer. UserId is taken from HttpContext (cookie or token). Returns the customer\u0027s active workout plan, or a message when none exists." + }, + "response": [ + + ] + }, + { + "name": "Create Customer", + "id": "d4e5f6a7-b8c9-4d0e-1f2a-3b4c5d6e7f8a", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"CreateCustomer\",\n \"Payload\": {\n \"fullname\": \"John Doe\",\n \"userName\": \"johndoe\",\n \"email\": \"john.doe@example.com\",\n \"phoneNumber\": \"+1234567890\",\n \"pushTokenId\": \"optional-push-token-id\"\n },\n \"Reference\": \"create-customer-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/customer", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "customer" + ] + }, + "description": "Create a new customer with basic information. This will create a User record first, then link it to the Customer with authentication setup. Required: fullname, userName, email, phoneNumber. Optional: pushTokenId. For creating customers with plan assignments (from client portal), use the CreateCustomerWithPlans endpoint in Client Management section." + }, + "response": [ + + ] + }, + { + "name": "Update Customer", + "id": "d5e6f7a8-b9c0-4d1e-2f3a-4b5c6d7e8f9a", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"UpdateCustomer\",\n \"Payload\": {\n \"customerId\": \"{{customerId}}\",\n \"cusName\": \"Joe Keery\",\n \"userName\": \"Joe\",\n \"email\": \"joe@example.com\",\n \"phoneNumber\": \"+1234567899\",\n \"isActive\": true\n },\n \"Reference\": \"update-customer-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/customer", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "customer" + ] + }, + "description": "Update an existing customer. Updates both Customer and User records. Only provided fields will be updated. Currently using static customerId for testing." + }, + "response": [ + + ] + }, + { + "name": "Delete Customer", + "id": "e5f6a7b8-c9d0-4e1f-2a3b-4c5d6e7f8a9b", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"DeleteCustomer\",\n \"Payload\": {\n \"customerId\": \"{{customerId}}\"\n },\n \"Reference\": \"delete-customer-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/customer", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "customer" + ] + }, + "description": "Delete a customer by their ID. Note: This does not delete the associated User record." + }, + "response": [ + + ] + } + ], + "id": "e6f7a8b9-c0d1-4e2f-3a4b-5c6d7e8f9a0b" + }, + { + "name": "Customer Plan Management", + "item": [ + { + "name": "Get All Customer Plans", + "id": "cp01-a1b2-c3d4-e5f6-7a8b9c0d1e2f", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetAllCustomerPlans\",\n \"Payload\": {\n \"includeInactive\": false\n },\n \"Reference\": \"get-all-customer-plans-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/customerplan", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "customerplan" + ] + }, + "description": "Retrieve all customer plans. Set includeInactive to true to get inactive plans as well." + }, + "response": [ + + ] + }, + { + "name": "Get Customer Plan by ID", + "id": "cp02-b2c3-d4e5-f6a7-8b9c0d1e2f3a", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetCustomerPlanById\",\n \"Payload\": {\n \"customerPlanId\": \"{{customerPlanId}}\"\n },\n \"Reference\": \"get-customer-plan-by-id-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/customerplan", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "customerplan" + ] + }, + "description": "Retrieve a specific customer plan by its ID." + }, + "response": [ + + ] + }, + { + "name": "Create Customer Plan", + "id": "cp03-c3d4-e5f6-a7b8-9c0d1e2f3a4b", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"CreateCustomerPlan\",\n \"Payload\": {\n \"planName\": \"Premium Monthly Plan\",\n \"planDescription\": \"Full access to all facilities and classes\",\n \"planPrice\": 5000,\n \"durationInDays\": 30\n },\n \"Reference\": \"create-customer-plan-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/customerplan", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "customerplan" + ] + }, + "description": "Create a new customer plan with name, description, price, and duration." + }, + "response": [ + + ] + }, + { + "name": "Update Customer Plan", + "id": "cp04-d4e5-f6a7-b8c9-0d1e2f3a4b5c", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"UpdateCustomerPlan\",\n \"Payload\": {\n \"customerPlanId\": \"{{customerPlanId}}\",\n \"planName\": \"Updated Plan Name\",\n \"planPrice\": 89.99,\n \"isActive\": true\n },\n \"Reference\": \"update-customer-plan-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/customerplan", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "customerplan" + ] + }, + "description": "Update an existing customer plan. Only provided fields will be updated." + }, + "response": [ + + ] + }, + { + "name": "Delete Customer Plan (Soft Delete)", + "id": "cp05-e5f6-a7b8-c9d0-1e2f3a4b5c6d", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"DeleteCustomerPlan\",\n \"Payload\": {\n \"customerPlanId\": \"{{customerPlanId}}\"\n },\n \"Reference\": \"delete-customer-plan-soft-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/customerplan", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "customerplan" + ] + }, + "description": "Soft delete a customer plan (sets IsActive to false). Plan is deactivated but not removed from database." + }, + "response": [ + + ] + } + ], + "id": "cp00-1a2b-3c4d-5e6f-7a8b9c0d1e2f" + }, + { + "name": "Customer Subscription Management", + "item": [ + { + "name": "Create Customer Subscription", + "id": "cs01-a1b2-c3d4-e5f6-7a8b9c0d1e2f", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"CreateCustomerSubscription\",\n \"Payload\": {\n \"customerId\": \"{{customerId}}\",\n \"customerPlanId\": \"{{customerPlanId}}\",\n \"onboardDate\": \"2026-01-29T00:00:00Z\"\n },\n \"Reference\": \"create-customer-subscription-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/customersubscription", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "customersubscription" + ] + }, + "description": "Create a new customer subscription. Automatically creates the first sub-item with plan details and calculates expiry date." + }, + "response": [ + + ] + }, + { + "name": "Get Customer Subscription by ID", + "id": "cs02-b2c3-d4e5-f6a7-8b9c0d1e2f3a", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetCustomerSubscriptionById\",\n \"Payload\": {\n \"subscriptionId\": \"{{subscriptionId}}\"\n },\n \"Reference\": \"get-customer-subscription-by-id-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/customersubscription", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "customersubscription" + ] + }, + "description": "Retrieve a specific customer subscription by ID with all sub-items." + }, + "response": [ + + ] + }, + { + "name": "Get Customer Subscriptions by Customer ID", + "id": "cs03-c3d4-e5f6-a7b8-9c0d1e2f3a4b", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetCustomerSubscriptionsByCustomerId\",\n \"Payload\": {\n \"customerId\": \"{{customerId}}\"\n },\n \"Reference\": \"get-customer-subscriptions-by-customer-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/customersubscription", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "customersubscription" + ] + }, + "description": "Retrieve all subscriptions for a specific customer." + }, + "response": [ + + ] + }, + { + "name": "Get Active Subscription by Customer ID", + "id": "cs04-d4e5-f6a7-b8c9-0d1e2f3a4b5c", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetActiveSubscriptionByCustomerId\",\n \"Payload\": {\n \"customerId\": \"{{customerId}}\"\n },\n \"Reference\": \"get-active-subscription-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/customersubscription", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "customersubscription" + ] + }, + "description": "Retrieve the currently active subscription for a customer with all sub-items." + }, + "response": [ + + ] + } + ], + "id": "cs00-1a2b-3c4d-5e6f-7a8b9c0d1e2f" + }, + { + "name": "Customer Invoice Management", + "item": [ + { + "name": "Create CustomerInvoice from Subscription", + "id": "f7a8b9c0-d1e2-4f3a-4b5c-6d7e8f9a0b1c", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"CreateCustomerInvoiceFromSubscription\",\n \"Payload\": {\n \"subscriptionId\": \"{{subscriptionId}}\"\n },\n \"Reference\": \"create-customerinvoice-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/customerinvoice", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "customerinvoice" + ] + }, + "description": "Create a customer invoice from a subscription. Automatically calculates total from plan price and all active sub-items. Sets status to \u0027Pending\u0027 by default." + }, + "response": [ + + ] + }, + { + "name": "Get CustomerInvoice by ID", + "id": "a8b9c0d1-e2f3-4a4b-5c6d-7e8f9a0b1c2d", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetCustomerInvoiceById\",\n \"Payload\": {\n \"customerInvoiceId\": \"{{customerInvoiceId}}\"\n },\n \"Reference\": \"get-customerinvoice-by-id-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/customerinvoice", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "customerinvoice" + ] + }, + "description": "Retrieve a specific customer invoice by its ID. Includes subscription, customer, user details." + }, + "response": [ + + ] + }, + { + "name": "Get CustomerInvoices by Customer ID", + "id": "b9c0d1e2-f3a4-4b5c-6d7e-8f9a0b1c2d3e", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetCustomerInvoicesByCustomerId\",\n \"Payload\": {\n \"customerId\": \"{{customerId}}\"\n },\n \"Reference\": \"get-customerinvoices-by-customer-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/customerinvoice", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "customerinvoice" + ] + }, + "description": "Retrieve all customer invoices for a specific customer, ordered by invoice date (most recent first)." + }, + "response": [ + + ] + }, + { + "name": "Get CustomerInvoices by Client ID", + "id": "c0d1e2f3-a4b5-4c6d-7e8f-9a0b1c2d3e4f", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetCustomerInvoicesByClientId\",\n \"Payload\": {},\n \"Reference\": \"get-customerinvoices-by-client-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/customerinvoice", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "customerinvoice" + ] + }, + "description": "Retrieve all customer invoices for the authenticated client, ordered by invoice date (most recent first). Client ID is automatically fetched from authentication context (currently using static ID for testing)." + }, + "response": [ + + ] + }, + { + "name": "Update CustomerInvoice Status", + "id": "d1e2f3a4-b5c6-4d7e-8f9a-0b1c2d3e4f5a", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"UpdateCustomerInvoiceStatus\",\n \"Payload\": {\n \"customerInvoiceId\": \"{{customerInvoiceId}}\",\n \"status\": \"Paid\"\n },\n \"Reference\": \"update-customerinvoice-status-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/customerinvoice", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "customerinvoice" + ] + }, + "description": "Update customer invoice status. Valid statuses: Pending, Paid, Failed, Cancelled, Refunded." + }, + "response": [ + + ] + }, + { + "name": "Delete CustomerInvoice", + "id": "e2f3a4b5-c6d7-4e8f-9a0b-1c2d3e4f5a6b", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"DeleteCustomerInvoice\",\n \"Payload\": {\n \"customerInvoiceId\": \"{{customerInvoiceId}}\"\n },\n \"Reference\": \"delete-customerinvoice-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/customerinvoice", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "customerinvoice" + ] + }, + "description": "Delete a customer invoice and all its related invoice items." + }, + "response": [ + + ] + }, + { + "name": "Calculate CustomerInvoice Total", + "id": "f3a4b5c6-d7e8-4f9a-0b1c-2d3e4f5a6b7c", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"CalculateCustomerInvoiceTotal\",\n \"Payload\": {\n \"customerInvoiceId\": \"{{customerInvoiceId}}\"\n },\n \"Reference\": \"calculate-customerinvoice-total-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/customerinvoice", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "customerinvoice" + ] + }, + "description": "Calculate and return the total amount for a customer invoice." + }, + "response": [ + + ] + } + ], + "id": "a4b5c6d7-e8f9-4a0b-1c2d-3e4f5a6b7c8d" + }, + { + "name": "Staff Management", + "item": [ + { + "name": "Get All Staff", + "id": "st01-a1b2-c3d4-e5f6-7a8b9c0d1e2f", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetAllStaff\",\n \"Payload\": {},\n \"Reference\": \"get-all-staff-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/staff", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "staff" + ] + }, + "description": "Retrieve all active staff members with their user and client details." + }, + "response": [ + + ] + }, + { + "name": "Get Staff by ID", + "id": "st02-b2c3-d4e5-f6a7-8b9c0d1e2f3a", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetStaffById\",\n \"Payload\": {\n \"staffId\": \"{{staffId}}\"\n },\n \"Reference\": \"get-staff-by-id-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/staff", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "staff" + ] + }, + "description": "Retrieve a specific staff member by their ID, including user and client details." + }, + "response": [ + + ] + }, + { + "name": "Get Staff by Client ID", + "id": "st03-c3d4-e5f6-a7b8-9c0d1e2f3a4b", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetStaffByClientId\",\n \"Payload\": {\n \"clientId\": \"{{clientId}}\"\n },\n \"Reference\": \"get-staff-by-client-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/staff", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "staff" + ] + }, + "description": "Retrieve all staff members belonging to a specific client." + }, + "response": [ + + ] + }, + { + "name": "Create Staff", + "id": "st04-d4e5-f6a7-b8c9-0d1e2f3a4b5c", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"CreateStaff\",\n \"Payload\": {\n \"userName\": \"Jane Smith\",\n \"email\": \"jane.smith@fitzone.com\",\n \"phoneNumber\": \"+1234567891\",\n \"clientId\": \"{{clientId}}\",\n \"staffPosition\": \"Personal Trainer\",\n \"department\": \"Fitness\",\n \"hireDate\": \"2026-01-29T00:00:00Z\"\n },\n \"Reference\": \"create-staff-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/staff", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "staff" + ] + }, + "description": "Create a new staff member. This will create a User record first, then link it to the Staff. Required fields: userName, email, phoneNumber, clientId. Optional: staffPosition, department, hireDate." + }, + "response": [ + + ] + }, + { + "name": "Update Staff", + "id": "st05-e5f6-a7b8-c9d0-1e2f3a4b5c6d", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"UpdateStaff\",\n \"Payload\": {\n \"staffId\": \"{{staffId}}\",\n \"userName\": \"Jane Smith Updated\",\n \"email\": \"jane.updated@fitzone.com\",\n \"staffPosition\": \"Senior Personal Trainer\",\n \"department\": \"Fitness Management\"\n },\n \"Reference\": \"update-staff-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/staff", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "staff" + ] + }, + "description": "Update an existing staff member. Updates both User and Staff records. Only provided fields will be updated." + }, + "response": [ + + ] + }, + { + "name": "Delete Staff (Soft Delete)", + "id": "st06-f6a7-b8c9-d0e1-2f3a4b5c6d7e", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"DeleteStaff\",\n \"Payload\": {\n \"staffId\": \"{{staffId}}\",\n \"softDelete\": true\n },\n \"Reference\": \"delete-staff-soft-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/staff", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "staff" + ] + }, + "description": "Soft delete a staff member (sets IsActive to false). Staff is deactivated but not removed from database." + }, + "response": [ + + ] + }, + { + "name": "Delete Staff (Hard Delete)", + "id": "st07-a7b8-c9d0-e1f2-3a4b5c6d7e8f", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"DeleteStaff\",\n \"Payload\": {\n \"staffId\": \"{{staffId}}\",\n \"softDelete\": false\n },\n \"Reference\": \"delete-staff-hard-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/staff", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "staff" + ] + }, + "description": "Permanently delete a staff member from the database. This action cannot be undone." + }, + "response": [ + + ] + } + ], + "id": "st00-1a2b-3c4d-5e6f-7a8b9c0d1e2f" + }, + { + "name": "Meal Management", + "item": [ + { + "name": "Get Meal Category List", + "id": "ml01-a1b2-c3d4-e5f6-7a8b9c0d1e2f", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetMealCategoryList\",\n \"Payload\": {},\n \"Reference\": \"get-meal-category-list-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/meal", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "meal" + ] + }, + "description": "Retrieve all active meal categories ordered by name." + }, + "response": [ + + ] + }, + { + "name": "Create Meal Category", + "id": "ml02-b2c3-d4e5-f6a7-8b9c0d1e2f3a", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"CreateMealCategory\",\n \"Payload\": {\n \"name\": \"Breakfast\",\n \"description\": \"Morning meal options\"\n },\n \"Reference\": \"create-meal-category-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/meal", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "meal" + ] + }, + "description": "Create a new meal category. Description is optional." + }, + "response": [ + + ] + }, + { + "name": "Get Meal Plan List", + "id": "ml03-c3d4-e5f6-a7b8-9c0d1e2f3a4b", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetMealPlanList\",\n \"Payload\": {},\n \"Reference\": \"get-meal-plan-list-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/meal", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "meal" + ] + }, + "description": "Retrieve all active meal plans with category, customer, and client details." + }, + "response": [ + + ] + }, + { + "name": "Get Meal Plan By ID", + "id": "ml04-d4e5-f6a7-b8c9-0d1e2f3a4b5c", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetMealPlanById\",\n \"Payload\": {\n \"mealPlanId\": \"{{mealPlanId}}\"\n },\n \"Reference\": \"get-meal-plan-by-id-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/meal", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "meal" + ] + }, + "description": "Retrieve a specific meal plan by its ID, including all meal items." + }, + "response": [ + + ] + }, + { + "name": "Get Meal Plan By Customer ID", + "id": "ml04b-d4e5-f6a7-b8c9-0d1e2f3a4b5d", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetMealPlanByCustomerId\",\n \"Payload\": {\n \"customerId\": \"{{customerId}}\"\n },\n \"Reference\": \"get-meal-plan-by-customer-id-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/meal", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "meal" + ] + }, + "description": "Retrieve all meal plans for a specific customer by their customer ID, including all meal items with nutritional totals. If customerId is not provided in payload, it will use the authenticated user\u0027s ID from the token." + }, + "response": [ + + ] + }, + { + "name": "Create Meal Plan", + "id": "ml05-e5f6-a7b8-c9d0-1e2f3a4b5c6d", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"CreateMealPlan\",\n \"Payload\": {\n \"name\": \"Basic Protein Plan\",\n \"description\": \"Meal plan focused on high protein intake\",\n \"isTemplate\": true,\n \"mealCategoryId\": \"{{mealCategoryId}}\",\n \"customerId\": \"{{customerId}}\",\n \"mealItems\": [\n {\n \"name\": \"Breakfast - Protein Bowl\",\n \"description\": \"High protein breakfast\",\n \"calories\": \"450\",\n \"protein\": \"35\",\n \"carbohydrates\": \"40\",\n \"fats\": \"15\",\n \"servingSize\": \"1 bowl\",\n \"mealItems\": [\n {\"key\": \"Eggs\", \"value\": \"3 whole\"},\n {\"key\": \"Oatmeal\", \"value\": \"50g\"}\n ],\n \"preparationNotes\": \"Cook eggs and serve with oatmeal\"\n },\n {\n \"name\": \"Lunch - Protein Bowl\",\n \"description\": \"High protein breakfast\",\n \"calories\": \"450\",\n \"protein\": \"35\",\n \"carbohydrates\": \"40\",\n \"fats\": \"15\",\n \"servingSize\": \"1 bowl\",\n \"mealItems\": [\n {\"key\": \"Eggs\", \"value\": \"5 whole\"},\n {\"key\": \"Oatmeal\", \"value\": \"500g\"}\n ],\n \"preparationNotes\": \"Cook eggs and serve with oatmeal\"\n },\n {\n \"name\": \"Dinner - Protein Bowl\",\n \"description\": \"High protein breakfast\",\n \"calories\": \"450\",\n \"protein\": \"35\",\n \"carbohydrates\": \"40\",\n \"fats\": \"15\",\n \"servingSize\": \"1 bowl\",\n \"mealItems\": [\n {\"key\": \"Eggs\", \"value\": \"30 whole\"},\n {\"key\": \"Oatmeal\", \"value\": \"500g\"}\n ],\n \"preparationNotes\": \"Cook eggs and serve with oatmeal\"\n }\n\n ]\n },\n \"Reference\": \"create-meal-plan-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/meal", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "meal" + ] + }, + "description": "Create a new meal plan with optional meal items. Required: name, mealCategoryId. Optional: description, isTemplate (default: false), customerIds (array of customer IDs), mealItems (array of meal objects). Backward compatible: customerId (single customer ID) still supported. If mealItems array is provided, meals will be created in one transaction. Each meal item requires at least a \u0027name\u0027, and other fields (calories, protein, etc.) are optional. ClientId is automatically fetched from authentication context. To assign/unassign customers after creation, use AssignMealToCustomer/UnassignMealFromCustomer endpoints." + }, + "response": [ + + ] + } + ], + "id": "ml00-1a2b-3c4d-5e6f-7a8b9c0d1e2f" + }, + { + "name": "Workout Management", + "item": [ + { + "name": "Get Workout Category List", + "id": "wk01-a1b2-c3d4-e5f6-7a8b9c0d1e2f", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetWorkoutCategoryList\",\n \"Payload\": {},\n \"Reference\": \"get-workout-category-list-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/workout", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "workout" + ] + }, + "description": "Retrieve all active workout categories ordered by name." + }, + "response": [ + + ] + }, + { + "name": "Create Workout Category", + "id": "wk02-b2c3-d4e5-f6a7-8b9c0d1e2f3a", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"CreateWorkoutCategory\",\n \"Payload\": {\n \"name\": \"Cardio Training\",\n \"description\": \"Boosts heart and lung health, such as running, walking, cycling\"\n },\n \"Reference\": \"create-workout-category-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/workout", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "workout" + ] + }, + "description": "Create a new workout category. Description is optional." + }, + "response": [ + + ] + }, + { + "name": "Get Workout Plan List", + "id": "wk03-c3d4-e5f6-a7b8-9c0d1e2f3a4b", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetWorkoutPlanList\",\n \"Payload\": {},\n \"Reference\": \"get-workout-plan-list-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/workout", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "workout" + ] + }, + "description": "Retrieve all active workout plans with category, customer, and client details." + }, + "response": [ + + ] + }, + { + "name": "Get Workout Plan By ID", + "id": "wk04-d4e5-f6a7-b8c9-0d1e2f3a4b5c", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetWorkoutPlanById\",\n \"Payload\": {\n \"workoutPlanId\": \"{{workoutPlanId}}\"\n },\n \"Reference\": \"get-workout-plan-by-id-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/workout", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "workout" + ] + }, + "description": "Retrieve a specific workout plan by its ID, including all workout items." + }, + "response": [ + + ] + }, + { + "name": "Get Workout Plan By Customer ID", + "id": "wk04b-d4e5-f6a7-b8c9-0d1e2f3a4b5d", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetWorkoutPlanByCustomerId\",\n \"Payload\": {\n \"customerId\": \"{{customerId}}\"\n },\n \"Reference\": \"get-workout-plan-by-customer-id-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/workout", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "workout" + ] + }, + "description": "Retrieve all workout plans for a specific customer by their customer ID, including all workout items with exercises. If customerId is not provided in payload, it will use the authenticated user\u0027s ID from the token." + }, + "response": [ + + ] + }, + { + "name": "Create Workout Plan", + "id": "wk05-e5f6-a7b8-c9d0-1e2f3a4b5c6d", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"CreateWorkoutPlan\",\n \"Payload\": {\n \"name\": \"Beginner Strength Program\",\n \"description\": \"Full body workout plan for beginners\",\n \"isTemplate\": true,\n \"workoutCategoryId\": \"{{workoutCategoryId}}\",\n \"workoutItems\": [\n {\n \"exerciseDay\": \"Upper Body Day\",\n \"description\": \"Compound upper body workout\",\n \"exercises\": {\n \"Bench Press\": \"3 sets x 10 reps\",\n \"Pull-ups\": \"3 sets x 8 reps\",\n \"Shoulder Press\": \"3 sets x 12 reps\"\n },\n \"howToDo\": \"{\\\"warmup\\\": \\\"5 mins cardio\\\", \\\"rest\\\": \\\"90 seconds between sets\\\", \\\"notes\\\": \\\"Focus on proper form\\\"}\",\n \"doURL\": \"https://example.com/exercises/upper-body\"\n },\n {\n \"exerciseDay\": \"Lower Body Day\",\n \"description\": \"Compound lower body workout\",\n \"exercises\": {\n \"Barbell Squat\": \"4 sets x 12 reps\",\n \"Romanian Deadlift\": \"3 sets x 10 reps\",\n \"Leg Press\": \"3 sets x 15 reps\"\n },\n \"howToDo\": \"{\\\"warmup\\\": \\\"5 mins cardio\\\", \\\"rest\\\": \\\"2 mins between sets\\\", \\\"notes\\\": \\\"Keep back straight\\\"}\",\n \"doURL\": \"https://example.com/exercises/lower-body\"\n }\n ]\n },\n \"Reference\": \"create-workout-plan-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/workout", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "workout" + ] + }, + "description": "Create a new workout plan with workout items. Required: name, workoutCategoryId. Optional: description, isTemplate (default: false), customerIds (array of customer IDs), workoutItems array. Backward compatible: customerId (single customer ID) still supported. Each workout item has: exerciseDay, description, exercises (JSON object), howToDo, doURL. ClientId is automatically fetched from authentication context. To assign/unassign customers after creation, use AssignWorkoutToCustomer/UnassignWorkoutFromCustomer endpoints." + }, + "response": [ + + ] + }, + { + "name": "Update Workout Plan", + "id": "wk05b-f6a7-b8c9-d0e1-2f3a4b5c6d7e", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"UpdateWorkoutPlan\",\n \"Payload\": {\n \"workoutPlanId\": \"{{workoutPlanId}}\",\n \"name\": \"Advanced Strength Program\",\n \"description\": \"Updated full body workout plan for intermediate level\",\n \"isTemplate\": false,\n \"workoutCategoryId\": \"{{workoutCategoryId}}\",\n \"workoutItems\": [\n {\n \"exerciseDay\": \"Push Day\",\n \"description\": \"Upper body push exercises\",\n \"exercises\": {\n \"Bench Press\": \"4 sets x 8 reps\",\n \"Incline Dumbbell Press\": \"3 sets x 10 reps\",\n \"Overhead Press\": \"3 sets x 10 reps\",\n \"Tricep Dips\": \"3 sets x 12 reps\"\n },\n \"howToDo\": \"{\\\"warmup\\\": \\\"10 mins cardio\\\", \\\"rest\\\": \\\"2 mins between sets\\\", \\\"notes\\\": \\\"Progressive overload\\\"}\",\n \"doURL\": \"https://example.com/exercises/push-day\"\n },\n {\n \"exerciseDay\": \"Pull Day\",\n \"description\": \"Upper body pull exercises\",\n \"exercises\": {\n \"Deadlift\": \"4 sets x 6 reps\",\n \"Pull-ups\": \"4 sets x 8 reps\",\n \"Barbell Rows\": \"3 sets x 10 reps\",\n \"Face Pulls\": \"3 sets x 15 reps\"\n },\n \"howToDo\": \"{\\\"warmup\\\": \\\"10 mins cardio\\\", \\\"rest\\\": \\\"2 mins between sets\\\", \\\"notes\\\": \\\"Focus on back engagement\\\"}\",\n \"doURL\": \"https://example.com/exercises/pull-day\"\n },\n {\n \"exerciseDay\": \"Leg Day\",\n \"description\": \"Lower body workout\",\n \"exercises\": {\n \"Squat\": \"5 sets x 5 reps\",\n \"Romanian Deadlift\": \"4 sets x 8 reps\",\n \"Leg Press\": \"3 sets x 12 reps\",\n \"Calf Raises\": \"4 sets x 15 reps\"\n },\n \"howToDo\": \"{\\\"warmup\\\": \\\"10 mins cardio + mobility\\\", \\\"rest\\\": \\\"3 mins between sets\\\", \\\"notes\\\": \\\"Heavy compound focus\\\"}\",\n \"doURL\": \"https://example.com/exercises/leg-day\"\n }\n ]\n },\n \"Reference\": \"update-workout-plan-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/workout", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "workout" + ] + }, + "description": "Update an existing workout plan and its workout items. Required: workoutPlanId. Optional: name, description, isTemplate, workoutCategoryId, workoutItems array. When workoutItems array is provided, all existing items are replaced with new ones (clean slate approach). Customer assignments are managed separately via AssignWorkoutToCustomer/UnassignWorkoutFromCustomer endpoints." + }, + "response": [ + + ] + }, + { + "name": "Delete Workout Plan", + "id": "wk05c-a7b8-c9d0-e1f2-3a4b5c6d7e8f", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"DeleteWorkoutPlan\",\n \"Payload\": {\n \"workoutPlanId\": \"{{workoutPlanId}}\"\n },\n \"Reference\": \"delete-workout-plan-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/workout", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "workout" + ] + }, + "description": "Soft delete a workout plan by setting IsActive to false. The plan remains in the database but is no longer active. Required: workoutPlanId." + }, + "response": [ + + ] + }, + { + "name": "Assign Workout to Customer", + "id": "wk05d-b8c9-d0e1-f2a3-4b5c6d7e8f9a", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"AssignWorkoutToCustomer\",\n \"Payload\": {\n \"workoutPlanId\": \"{{workoutPlanId}}\",\n \"customerId\": \"{{customerId}}\",\n \"notes\": \"Custom notes for this assignment\"\n },\n \"Reference\": \"assign-workout-customer-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/workout", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "workout" + ] + }, + "description": "Assign an existing workout plan to a customer. Creates a CustomerWorkout record with AssignedDate. Required: workoutPlanId, customerId. Optional: notes." + }, + "response": [ + + ] + }, + { + "name": "Unassign Workout from Customer", + "id": "wk05e-c9d0-e1f2-a3b4-5c6d7e8f9a0b", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"UnassignWorkoutFromCustomer\",\n \"Payload\": {\n \"workoutPlanId\": \"{{workoutPlanId}}\",\n \"customerId\": \"{{customerId}}\"\n },\n \"Reference\": \"unassign-workout-customer-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/workout", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "workout" + ] + }, + "description": "Unassign a workout plan from a customer. Soft deletes the CustomerWorkout record by setting IsActive to false. Required: workoutPlanId, customerId." + }, + "response": [ + + ] + }, + { + "name": "Get Workout Items by Workout Plan ID", + "id": "wk06-f6a7-b8c9-d0e1-2f3a4b5c6d7e", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetWorkoutItemsByWorkoutPlanId\",\n \"Payload\": {\n \"workoutPlanId\": \"{{workoutPlanId}}\"\n },\n \"Reference\": \"get-workout-items-by-plan-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/workout", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "workout" + ] + }, + "description": "Retrieve all workout items for a specific workout plan." + }, + "response": [ + + ] + }, + { + "name": "Create Workout Item", + "id": "wk07-a7b8-c9d0-e1f2-3a4b5c6d7e8f", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"CreateWorkoutItem\",\n \"Payload\": {\n \"exerciseDay\": \"Core Training Day\",\n \"description\": \"Abdominal and core strengthening exercises\",\n \"exercises\": {\n \"Plank\": \"3 sets x 60 seconds\",\n \"Russian Twists\": \"3 sets x 20 reps\",\n \"Leg Raises\": \"3 sets x 15 reps\",\n \"Mountain Climbers\": \"3 sets x 30 seconds\"\n },\n \"howToDo\": \"{\\\"sets\\\": 3, \\\"rest\\\": \\\"60 seconds\\\", \\\"notes\\\": \\\"Focus on engaging core muscles throughout\\\"}\",\n \"doURL\": \"https://example.com/exercises/core-training\",\n \"workoutPlanId\": \"{{workoutPlanId}}\"\n },\n \"Reference\": \"create-workout-item-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/workout", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "workout" + ] + }, + "description": "Create a new workout item. Required: exerciseDay, workoutPlanId. Optional: description, exercises (JSON object with exercise names and counts/reps), howToDo (structured JSON string), doURL." + }, + "response": [ + + ] + } + ], + "id": "wk00-1a2b-3c4d-5e6f-7a8b9c0d1e2f" + }, + { + "name": "Feed Management", + "item": [ + { + "name": "Get Feed List", + "id": "feed01-a1b2-c3d4-e5f6-7a8b9c0d1e2f", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetFeedList\",\n \"Payload\": {\n \"skip\": 0,\n \"take\": 10\n },\n \"Reference\": \"get-feed-list-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/feed", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "feed" + ] + }, + "description": "Retrieve paginated feed list with like counts. Parameters: skip (default: 0), take (default: 10). Returns feed posts with like counts." + }, + "response": [ + + ] + }, + { + "name": "Get Feed Post By ID", + "id": "feed02-a2b3-c4d5-e6f7-8a9b0c1d2e3f", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetFeedPostById\",\n \"Payload\": {\n \"postId\": \"{{feedPostId}}\"\n },\n \"Reference\": \"get-feed-post-by-id-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/feed", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "feed" + ] + }, + "description": "Retrieve a specific feed post by its ID. Required: postId. Returns complete feed post details." + }, + "response": [ + + ] + }, + { + "name": "Create Feed", + "id": "feed03-b2c3-d4e5-f6a7-8b9c0d1e2f3a", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"CreateFeed\",\n \"Payload\": {\n \"caption\": \"Amazing workout session today!\",\n \"description\": \"Completed my leg day routine with personal best on squats\",\n \"document\": [\n {\n \"FileName\": \"workout-photo.jpg\",\n \"Base64\": \"VGhpcyBpcyBhIHNhbXBsZSBpbWFnZSBjb250ZW50\"\n }\n ]\n },\n \"Reference\": \"create-feed-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/feed", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "feed" + ] + }, + "description": "Create a new feed post with document upload. UserId is automatically extracted from authentication context (token/cookie). Required: document array. Optional: caption, description. FileType in document defaults to 3 (Feed document type) if not provided. Document is uploaded first, then feed post is created with the document reference." + }, + "response": [ + + ] + }, + { + "name": "Like Post", + "id": "feed04-c3d4-e5f6-a7b8-9c0d1e2f3a4b", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"Like\",\n \"Payload\": {\n \"postId\": \"{{feedPostId}}\",\n \"userId\": \"{{userId}}\"\n },\n \"Reference\": \"like-post-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/feed", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "feed" + ] + }, + "description": "Like a feed post. Required: postId, userId. Returns success if post is liked, or message if already liked." + }, + "response": [ + + ] + }, + { + "name": "Unlike Post", + "id": "feed05-d4e5-f6a7-b8c9-0d1e2f3a4b5c", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"Unlike\",\n \"Payload\": {\n \"postId\": \"{{feedPostId}}\",\n \"userId\": \"{{userId}}\"\n },\n \"Reference\": \"unlike-post-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/feed", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "feed" + ] + }, + "description": "Unlike a previously liked feed post. Required: postId, userId. Returns success if post is unliked, or message if not liked yet." + }, + "response": [ + + ] + }, + { + "name": "Check If Liked", + "id": "feed06-e5f6-a7b8-c9d0-1e2f3a4b5c6d", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"IsLiked\",\n \"Payload\": {\n \"postId\": \"{{feedPostId}}\",\n \"userId\": \"{{userId}}\"\n },\n \"Reference\": \"is-liked-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/feed", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "feed" + ] + }, + "description": "Check if a user has liked a specific post. Required: postId, userId. Returns { isLiked: true/false }." + }, + "response": [ + + ] + }, + { + "name": "Show Feed By Client ID", + "id": "feed07-f6a7-b8c9-d0e1-2f3a4b5c6d7e", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"ShowFeedByClientId\",\n \"Payload\": {\n \"clientId\": \"{{clientId}}\"\n },\n \"Reference\": \"show-feed-by-client-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/feed", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "feed" + ] + }, + "description": "Retrieve all feed posts created by a specific client with document data and like counts. Required: clientId. Returns feed list filtered by client." + }, + "response": [ + + ] + }, + { + "name": "Delete Feed Post", + "id": "feed08-a8b9-c0d1-e2f3-4a5b6c7d8e9f", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"DeleteFeedByPostId\",\n \"Payload\": {\n \"postId\": \"{{feedPostId}}\"\n },\n \"Reference\": \"delete-feed-post-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/feed", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "feed" + ] + }, + "description": "Delete a feed post by its ID. Required: postId. Only the creator can delete their own posts." + }, + "response": [ + + ] + } + ], + "id": "feed00-1a2b-3c4d-5e6f-7a8b9c0d1e2f" + }, + { + "name": "Document Management", + "item": [ + { + "name": "Upload Document", + "id": "doc01-a1b2-c3d4-e5f6-7a8b9c0d1e2f", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"UploadDocument\",\n \"Payload\": {\n \"document\": [\n {\n \"FileName\": \"sample-document.pdf\",\n \"Base64\": \"VGhpcyBpcyBhIHNhbXBsZSBkb2N1bWVudCBjb250ZW50Lg==\",\n \"FileType\": \"{{documentTypeId}}\"\n }\n ],\n \"IsTicketDoc\": false,\n \"TicketId\": 0\n },\n \"Reference\": \"upload-document-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/document", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "document" + ] + }, + "description": "Upload one or multiple documents. Supports: .pdf, .jpg, .jpeg, .png, .docx, .mp4, .mov, .avi, .mkv, .webm, .3gp. Max 10MB per file. Set IsTicketDoc=true and provide TicketId for ticket-related documents." + }, + "response": [ + + ] + }, + { + "name": "Get Documents By User ID", + "id": "doc02-b2c3-d4e5-f6a7-8b9c0d1e2f3a", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetDocumentsByUserId\",\n \"Payload\": {\n \"userId\": \"{{userId}}\"\n },\n \"Reference\": \"get-user-documents-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/document", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "document" + ] + }, + "description": "Retrieve all active documents for a specific user, ordered by creation date (newest first)." + }, + "response": [ + + ] + }, + { + "name": "Get Document By ID", + "id": "doc03-c3d4-e5f6-a7b8-9c0d1e2f3a4b", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetDocumentById\",\n \"Payload\": {\n \"documentId\": \"{{documentId}}\"\n },\n \"Reference\": \"get-document-by-id-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/document", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "document" + ] + }, + "description": "Retrieve a specific document by its ID. Returns only if document is active." + }, + "response": [ + + ] + }, + { + "name": "Remove Document", + "id": "doc04-d4e5-f6a7-b8c9-0d1e2f3a4b5c", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"RemoveDocument\",\n \"Payload\": {\n \"documentId\": \"{{documentId}}\"\n },\n \"Reference\": \"remove-document-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/document", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "document" + ] + }, + "description": "Soft delete a document by setting IsActive to false. Document remains in database but won\u0027t appear in queries." + }, + "response": [ + + ] + } + ], + "id": "doc00-0a1b-2c3d-4e5f-6a7b8c9d0e1f" + }, + { + "name": "Chat Management", + "item": [ + { + "name": "Get or Create Chat", + "id": "chat01-a1b2-c3d4-e5f6-7a8b9c0d1e2f", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetOrCreateChatAsync\",\n \"Payload\": {\n \"currentUserId\": \"{{userId}}\",\n \"otherUserId\": \"{{otherUserId}}\"\n },\n \"Reference\": \"get-or-create-chat-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/chat", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "chat" + ] + }, + "description": "Get an existing chat between two users or create a new one if it doesn\u0027t exist." + }, + "response": [ + + ] + }, + { + "name": "Send Message", + "id": "chat02-b2c3-d4e5-f6a7-8b9c0d1e2f3a", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"SendMessage\",\n \"Payload\": {\n \"chatId\": \"{{chatId}}\",\n \"senderId\": \"{{userId}}\",\n \"message\": \"Hello! This is a test message.\",\n \"type\": \"text\"\n },\n \"Reference\": \"send-message-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/chat", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "chat" + ] + }, + "description": "Send a message in an existing chat. Message can be up to 100,000 characters. Type can be \u0027text\u0027, \u0027image\u0027, \u0027file\u0027, etc." + }, + "response": [ + + ] + }, + { + "name": "Get Messages", + "id": "chat03-c3d4-e5f6-a7b8-9c0d1e2f3a4b", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetMessages\",\n \"Payload\": {\n \"chatId\": \"{{chatId}}\",\n \"userId\": \"{{userId}}\",\n \"skip\": 0,\n \"take\": 30\n },\n \"Reference\": \"get-messages-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/chat", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "chat" + ] + }, + "description": "Retrieve messages from a chat with pagination. Take must be between 1 and 100, defaults to 30." + }, + "response": [ + + ] + } + ], + "id": "chat00-0a1b-2c3d-4e5f-6a7b8c9d0e1f" + }, + { + "name": "Access Device Management", + "item": [ + { + "name": "Add Device", + "id": "ad01-a1b2-c3d4-e5f6-7a8b9c0d1e2f", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"AddDevice\",\n \"Payload\": {\n \"DeviceName\": \"Front Gate Controller\",\n \"FKClientId\": \"{{clientId}}\"\n },\n \"Reference\": \"add-device-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/accessdevice", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "accessdevice" + ] + }, + "description": "Create a new access device record for a client. Connection details (IP/port/credentials) now live on the on-prem agent, not in the cloud, so they are no longer sent here. (Agents normally self-register devices via POST /api/agent/devices.)" + }, + "response": [ + + ] + }, + { + "name": "Remove Device", + "id": "ad02-b2c3-d4e5-f6a7-8b9c0d1e2f3a", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"RemoveDevice\",\n \"Payload\": {\n \"clientId\": \"{{clientId}}\"\n },\n \"Reference\": \"remove-device-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/accessdevice", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "accessdevice" + ] + }, + "description": "Deactivate access device for a given client." + }, + "response": [ + + ] + }, + { + "name": "Add Device User", + "id": "ad03-c3d4-e5f6-a7b8-9c0d1e2f3a4b", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"AddUser\",\n \"Payload\": {\n \"FKDeviceId\": \"{{accessDeviceId}}\",\n \"FKCustomerId\": \"{{customerId}}\",\n \"Id\": {{deviceUserId}},\n \"Name\": \"Test Member\"\n },\n \"Reference\": \"add-device-user-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/accessdevice", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "accessdevice" + ] + }, + "description": "Create the CustomerAccess record and ENQUEUE an 'AddUser' DeviceCommand (Status=Pending) for the on-prem agent to execute against the device. The cloud no longer calls the device directly." + }, + "response": [ + + ] + }, + { + "name": "Whitelist Member", + "id": "ad04-d4e5-f6a7-b8c9-0d1e2f3a4b5c", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"WhiteListMember\",\n \"Payload\": {\n \"customerId\": \"{{customerId}}\"\n },\n \"Reference\": \"whitelist-member-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/accessdevice", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "accessdevice" + ] + }, + "description": "Set the member active and ENQUEUE a 'WhiteListMember' DeviceCommand for the on-prem agent to apply on the device." + }, + "response": [ + + ] + }, + { + "name": "Blacklist Member", + "id": "ad05-e5f6-a7b8-c9d0-1e2f3a4b5c6d", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"BlackListMember\",\n \"Payload\": {\n \"customerId\": \"{{customerId}}\"\n },\n \"Reference\": \"blacklist-member-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/accessdevice", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "accessdevice" + ] + }, + "description": "Set the member inactive and ENQUEUE a 'BlackListMember' DeviceCommand for the on-prem agent to apply on the device." + }, + "response": [ + + ] + }, + { + "name": "Generate Pairing Code", + "id": "ad06-f6a7-b8c9-d0e1-2f3a4b5c6d7e", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GeneratePairingCode\",\n \"Payload\": {\n \"clientId\": \"{{clientId}}\"\n },\n \"Reference\": \"generate-pairing-code-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/accessdevice", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "accessdevice" + ] + }, + "description": "Admin-side. Creates a short-lived (15 min) pairing code bound to the client. Hand the returned code to an on-prem agent so it can bootstrap-enroll via POST /api/agent/enroll." + }, + "response": [ + + ] + } + ], + "id": "ad00-9a8b-7c6d-5e4f-3a2b1c0d9e8f" + }, + { + "name": "Agent (On-Prem)", + "item": [ + { + "name": "Enroll Agent (bootstrap, no api-key)", + "id": "ag01-a1b2-c3d4-e5f6-7a8b9c0d1e2f", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"code\": \"{{pairingCode}}\",\n \"agentName\": \"Front Desk Agent\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/agent/enroll", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "agent", + "enroll" + ] + }, + "description": "Exchange a pairing code for a new Agent. Returns { agentId, apiKey } ONCE — store the apiKey in the {{agentApiKey}} collection variable for the other agent calls." + }, + "response": [ + + ] + }, + { + "name": "Register Device", + "id": "ag02-b2c3-d4e5-f6a7-8b9c0d1e2f3a", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "X-Api-Key", + "value": "{{agentApiKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"deviceName\": \"Front Gate Controller\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/agent/devices", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "agent", + "devices" + ] + }, + "description": "AgentApiKey. Creates an AccessDevice for the authenticated agent's client (resolved from the key, not the body). Returns { deviceId }." + }, + "response": [ + + ] + }, + { + "name": "Push Attendance", + "id": "ag03-c3d4-e5f6-a7b8-9c0d1e2f3a4b", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "X-Api-Key", + "value": "{{agentApiKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"deviceId\": \"{{accessDeviceId}}\",\n \"events\": [\n {\n \"employeeNoString\": \"{{deviceUserId}}\",\n \"time\": \"2026-06-17T08:30:00Z\",\n \"attendanceStatus\": \"checkIn\"\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/agent/attendance", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "agent", + "attendance" + ] + }, + "description": "AgentApiKey. The agent pulls events from the device locally and pushes them here. Server verifies the device + each member belong to the agent's client, runs the dedup logic, and returns { inserted }." + }, + "response": [ + + ] + }, + { + "name": "Get Pending Commands", + "id": "ag04-d4e5-f6a7-b8c9-0d1e2f3a4b5c", + "request": { + "method": "GET", + "header": [ + { + "key": "X-Api-Key", + "value": "{{agentApiKey}}", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/agent/commands", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "agent", + "commands" + ] + }, + "description": "AgentApiKey. Returns Pending DeviceCommands for the agent's client and marks them InProgress." + }, + "response": [ + + ] + }, + { + "name": "Submit Command Result", + "id": "ag05-e5f6-a7b8-c9d0-1e2f3a4b5c6d", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "X-Api-Key", + "value": "{{agentApiKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"success\": true,\n \"resultJson\": \"{\\\"deviceResponse\\\":\\\"ok\\\"}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/agent/commands/{{commandId}}/result", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "agent", + "commands", + "{{commandId}}", + "result" + ] + }, + "description": "AgentApiKey. Reports execution result; sets Status Completed/Failed, ResultJson, ProcessedAt. Verifies the command belongs to the agent's client." + }, + "response": [ + + ] + }, + { + "name": "Heartbeat", + "id": "ag06-f6a7-b8c9-d0e1-2f3a4b5c6d7e", + "request": { + "method": "POST", + "header": [ + { + "key": "X-Api-Key", + "value": "{{agentApiKey}}", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/agent/heartbeat", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "agent", + "heartbeat" + ] + }, + "description": "AgentApiKey. Updates Agent.LastSeenAt and returns server time." + }, + "response": [ + + ] + } + ], + "id": "ag00-9a8b-7c6d-5e4f-3a2b1c0d9e8f" + }, + { + "name": "Attendance Management", + "item": [ + { + "name": "Get All Attendance Records", + "id": "at01-a1b2-c3d4-e5f6-7a8b9c0d1e2f", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetAllAttendanceRecords\",\n \"Payload\": {\n \"clientId\": \"{{clientId}}\"\n },\n \"Reference\": \"get-all-attendance-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/attendance", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "attendance" + ] + }, + "description": "Retrieve all raw attendance records for a client from the database, ordered by most recent EventTime. Includes full Attendance row data with Customer details." + }, + "response": [ + + ] + }, + { + "name": "Get Attendance Counts", + "id": "at02-b2c3-d4e5-f6a7-8b9c0d1e2f3a", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetAttendanceCounts\",\n \"Payload\": {\n \"clientId\": \"{{clientId}}\",\n \"fromDate\": \"2026-05-01\",\n \"toDate\": \"2026-05-18\"\n },\n \"Reference\": \"get-attendance-counts-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/attendance", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "attendance" + ] + }, + "description": "Get attendance counts per customer for a client within a date range (filtered by EventTime). Returns customerId, customerName, and attendanceCount. Date rules: if only fromDate is provided, toDate defaults to today; if neither is provided, defaults to the current month (1st to today); if both are provided, uses them as-is." + }, + "response": [ + + ] + }, + { + "name": "Get Attendance Counts (Current Month Default)", + "id": "at02b-b2c3-d4e5-f6a7-8b9c0d1e2f3b", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetAttendanceCounts\",\n \"Payload\": {\n \"clientId\": \"{{clientId}}\"\n },\n \"Reference\": \"get-attendance-counts-current-month-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/attendance", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "attendance" + ] + }, + "description": "Get attendance counts per customer for the current month (1st of month to today). No fromDate or toDate needed — defaults automatically apply." + }, + "response": [ + + ] + }, + { + "name": "Get Today Attendance", + "id": "at03-c3d4-e5f6-a7b8-9c0d1e2f3a4b", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetTodayAttendance\",\n \"Payload\": {\n \"clientId\": \"{{clientId}}\"\n },\n \"Reference\": \"get-today-attendance-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/attendance", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "attendance" + ] + }, + "description": "Retrieve all attendance records for today (UTC) for a client. Returns customerName and EventTime, ordered by EventTime ascending." + }, + "response": [ + + ] + } + ], + "id": "at00-1a2b-3c4d-5e6f-7a8b9c0d1e2f" + }, + { + "name": "Client Stats", + "id": "cst00-1a2b-3c4d-5e6f-7a8b9c0d1e2f", + "item": [ + { + "name": "Get Total Customer Count", + "id": "cst01-a1b2-c3d4-e5f6-7a8b9c0d1e2f", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetTotalCustomerCount\",\n \"Payload\": {},\n \"Reference\": \"gettotalcustomercount-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/clientstat", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clientstat" + ] + }, + "description": "Returns total count of active customers registered under the authenticated client. ClientId is resolved from auth token." + }, + "response": [ + + ] + }, + { + "name": "Get Monthly Customer Count", + "id": "cst02-b2c3-d4e5-f6a7-8b9c0d1e2f3a", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetMonthlyCustomerCount\",\n \"Payload\": {},\n \"Reference\": \"getmonthlycustomercount-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/clientstat", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clientstat" + ] + }, + "description": "Returns count of active customers onboarded in the current calendar month for the authenticated client." + }, + "response": [ + + ] + }, + { + "name": "Get Last Month Customer Count", + "id": "cst03-c3d4-e5f6-a7b8-9c0d1e2f3a4b", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetLastMonthCustomerCount\",\n \"Payload\": {},\n \"Reference\": \"getlastmonthcustomercount-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/clientstat", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clientstat" + ] + }, + "description": "Returns count of active customers onboarded in the previous calendar month for the authenticated client." + }, + "response": [ + + ] + }, + { + "name": "Get Total Workout Plan Count", + "id": "cst04-d4e5-f6a7-b8c9-0d1e2f3a4b5c", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetTotalWorkoutPlanCount\",\n \"Payload\": {},\n \"Reference\": \"gettotalworkoutplancount-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/clientstat", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clientstat" + ] + }, + "description": "Returns total count of active workout plans belonging to the authenticated client (filtered by FKClientId)." + }, + "response": [ + + ] + }, + { + "name": "Get Total Meal Plan Count", + "id": "cst05-e5f6-a7b8-c9d0-1e2f3a4b5c6d", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetTotalMealPlanCount\",\n \"Payload\": {},\n \"Reference\": \"gettotalmealplancount-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/clientstat", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clientstat" + ] + }, + "description": "Returns total count of active meal plans belonging to the authenticated client (filtered by FKClientId)." + }, + "response": [ + + ] + }, + { + "name": "Get Last Five Attendances", + "id": "cst06-f6a7-b8c9-d0e1-2f3a4b5c6d7e", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetLastFiveAttendances\",\n \"Payload\": {},\n \"Reference\": \"getlastfiveattendances-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/clientstat", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clientstat" + ] + }, + "description": "Returns the 5 most recent attendance records for the authenticated client, ordered by EventTime descending. Includes customer details." + }, + "response": [ + + ] + }, + { + "name": "Get Last Five Payments", + "id": "cst07-a7b8-c9d0-e1f2-3a4b5c6d7e8f", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetLastFivePayments\",\n \"Payload\": {},\n \"Reference\": \"getlastfivepayments-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/clientstat", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clientstat" + ] + }, + "description": "Returns the 5 most recent active payment transactions for the authenticated client, ordered by PayDate descending. Includes customer details." + }, + "response": [ + + ] + }, + { + "name": "Get Last Month Revenue", + "id": "cst08-b8c9-d0e1-f2a3-4b5c6d7e8f9a", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetLastMonthRevenue\",\n \"Payload\": {},\n \"Reference\": \"getlastmonthrevenue-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/clientstat", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clientstat" + ] + }, + "description": "Returns the total PayAmount sum from active payment transactions in the previous calendar month for the authenticated client." + }, + "response": [ + + ] + }, + { + "name": "Get Monthly Revenue Variation", + "id": "cst09-c9d0-e1f2-a3b4-5c6d7e8f9a0b", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetMonthlyRevenueVariation\",\n \"Payload\": {},\n \"Reference\": \"getmonthlyrevenuevariation-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/clientstat", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clientstat" + ] + }, + "description": "Returns an array of 10 consecutive months (oldest to current) each with year, month, monthLabel, and revenue. Months with no payments return revenue: 0." + }, + "response": [ + + ] + }, + { + "name": "Get Total Revenue", + "id": "cst10-d0e1-f2a3-b4c5-6d7e8f9a0b1c", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\\n \"FunctionName\": \"GetTotalRevenue\",\n \"Payload\": {},\n \"Reference\": \"gettotalrevenue-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/clientstat", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clientstat" + ] + }, + "description": "Returns the all-time total PayAmount sum from active payment transactions for the authenticated client (filtered by FK_ClientId). Returns { totalRevenue: decimal }." + }, + "response": [ + + ] + }, + { + "name": "Get Client Stat Summary", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"FunctionName\": \"GetClientStatSummary\",\n \"Payload\": {},\n \"Reference\": \"getclientstatsummary-001\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/clientstat", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "clientstat" + ] + } + }, + "response": [ + + ] + } + ] + }, + { + "name": "Payment Management", + "id": "pay00-1a2b-3c4d-5e6f-7a8b9c0d1e2f", + "item": [ + { + "name": "Create Customer Payment", + "id": "pay01-a1b2-c3d4-e5f6-7a8b9c0d1e2f", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\\n \"FunctionName\": \"CreateCustomerPayment\",\n \"Payload\": {\"refType\":\"invoice\",\"payAmount\":1500,\"bankRefNo\":\"BNK-REF-001\",\"customerInvoiceId\":\"{{customerInvoiceId}}\",\"cardId\":\"{{cardId}}\",\"payNote\":\"Monthly subscription payment\",\"paymentReferenceId\":\"PAY-REF-001\"},\n \"Reference\": \"createcustomerpayment-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/payment", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "payment" + ] + }, + "description": "Create a payment transaction for a customer invoice. Required: customerInvoiceId, payAmount. Optional: cardId, payNote, refType (default: invoice), bankRefNo, paymentReferenceId. Sets BeneficiaryType to Customer, IsApproved to false." + }, + "response": [ + + ] + }, + { + "name": "Create Client Payment", + "id": "pay02-b2c3-d4e5-f6a7-8b9c0d1e2f3a", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\\n \"FunctionName\": \"CreateClientPayment\",\n \"Payload\": {\"payAmount\":5000,\"bankRefNo\":\"BNK-REF-002\",\"refType\":\"invoice\",\"cardId\":\"{{cardId}}\",\"paymentReferenceId\":\"PAY-REF-002\",\"clientInvoiceId\":\"{{clientInvoiceId}}\",\"payNote\":\"Platform subscription payment\"},\n \"Reference\": \"createclientpayment-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/payment", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "payment" + ] + }, + "description": "Create a payment transaction for a client invoice. Required: clientInvoiceId, payAmount. Optional: cardId, payNote, refType (default: invoice), bankRefNo, paymentReferenceId. Sets BeneficiaryType to Client, IsApproved to false." + }, + "response": [ + + ] + }, + { + "name": "Get Payment By ID", + "id": "pay03-c3d4-e5f6-a7b8-9c0d1e2f3a4b", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\\n \"FunctionName\": \"GetPaymentById\",\n \"Payload\": {\"attemptId\":\"{{attemptId}}\"},\n \"Reference\": \"getpaymentbyid-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/payment", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "payment" + ] + }, + "description": "Retrieve a specific payment transaction by its ID." + }, + "response": [ + + ] + }, + { + "name": "Get Payments By Customer Invoice", + "id": "pay04-d4e5-f6a7-b8c9-0d1e2f3a4b5c", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\\n \"FunctionName\": \"GetPaymentsByCustomerInvoice\",\n \"Payload\": {\"customerInvoiceId\":\"{{customerInvoiceId}}\"},\n \"Reference\": \"getpaymentsbycustomerinvoice-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/payment", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "payment" + ] + }, + "description": "Retrieve all payment transactions linked to a specific customer invoice." + }, + "response": [ + + ] + }, + { + "name": "Get Payments By Client Invoice", + "id": "pay05-e5f6-a7b8-c9d0-1e2f3a4b5c6d", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\\n \"FunctionName\": \"GetPaymentsByClientInvoice\",\n \"Payload\": {\"clientInvoiceId\":\"{{clientInvoiceId}}\"},\n \"Reference\": \"getpaymentsbyclientinvoice-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/payment", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "payment" + ] + }, + "description": "Retrieve all payment transactions linked to a specific client invoice." + }, + "response": [ + + ] + }, + { + "name": "Approve Payment", + "id": "pay06-f6a7-b8c9-d0e1-2f3a4b5c6d7e", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\\n \"FunctionName\": \"ApprovePayment\",\n \"Payload\": {\"attemptId\":\"{{attemptId}}\"},\n \"Reference\": \"approvepayment-001\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/payment", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "payment" + ] + }, + "description": "Approve a payment transaction: sets IsApproved to true, PayStatus to Approved. Also marks the linked customer or client invoice as Paid." + }, + "response": [ + + ] + } + ] + } + ], + "variable": [ + { + "id": "5d5164be-dcbe-4620-8061-6b1052e838f5", + "key": "baseUrl", + "value": "https://localhost:7111/api", + "type": "string" + }, + { + "id": "9032ff5c-d6f0-4145-83a8-796882bf1fc6", + "key": "userId", + "value": "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d", + "type": "string" + }, + { + "id": "0145e5af-ead0-4961-9e03-20241b2ce083", + "key": "ticketId", + "value": "c5d6e7f8-a9b0-4c1d-2e3f-4a5b6c7d8e9f", + "type": "string" + }, + { + "id": "68aa771c-78e1-49a1-b0dc-98b3d675b4d0", + "key": "ticketCategoryId", + "value": "f6a7b8c9-d0e1-4f5a-3b4c-5d6e7f8a9b0c", + "type": "string" + }, + { + "id": "668d1aee-7189-4652-806f-8af893f7549d", + "key": "ticketStatusIdPending", + "value": "c9d0e1f2-a3b4-4c5d-6e7f-8a9b0c1d2e3f", + "type": "string" + }, + { + "id": "5ef4c07b-5b5a-427f-8d68-d3d45e1a436c", + "key": "ticketStatusIdOpen", + "value": "d0e1f2a3-b4c5-4d6e-7f8a-9b0c1d2e3f4a", + "type": "string" + }, + { + "id": "4205b388-484e-4b39-868b-59e14b3e8f2e", + "key": "ticketStatusIdClosed", + "value": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b", + "type": "string" + }, + { + "id": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e", + "key": "subscriptionId", + "value": "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d", + "type": "string" + }, + { + "id": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f", + "key": "clientInvoiceId", + "value": "d6e7f8a9-b0c1-4d5e-8f9a-5b6c7d8e9f0a", + "type": "string" + }, + { + "id": "d3e4f5a6-b7c8-4d9e-0f1a-2b3c4d5e6f7a", + "key": "clientId", + "value": "c3d4e5f6-a7b8-9c0d-1e2f-3a4b5c6d7e8f", + "type": "string" + }, + { + "id": "042854f7-80c2-416b-93a7-11dc14e6ebb1", + "key": "customerId", + "value": "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f9a", + "type": "string" + }, + { + "id": "6d9c5b98-db73-4df8-ad11-2551220489a7", + "key": "customerInvoiceId", + "value": "e5f6a7b8-c9d0-1e2f-3a4b-5c6d7e8f9a0b", + "type": "string" + }, + { + "id": "cf418c93-f89d-4e70-8ae8-5a7b05855243", + "key": "customerPlanId", + "value": "f6a7b8c9-d0e1-2f3a-4b5c-6d7e8f9a0b1c", + "type": "string" + }, + { + "id": "74644f80-258e-4326-88e6-89a0b99bc49c", + "key": "subscriptionId", + "value": "a7b8c9d0-e1f2-3a4b-5c6d-7e8f9a0b1c2d", + "type": "string" + }, + { + "id": "c8d9e0f1-a2b3-4c4d-5e6f-7a8b9c0d1e2f", + "key": "staffId", + "value": "b8c9d0e1-f2a3-4b5c-6d7e-8f9a0b1c2d3e", + "type": "string" + }, + { + "id": "d9e0f1a2-b3c4-5d6e-7f8a-9b0c1d2e3f4a", + "key": "mealCategoryId", + "value": "c9d0e1f2-a3b4-5c6d-7e8f-9a0b1c2d3e4f", + "type": "string" + }, + { + "id": "e0f1a2b3-c4d5-6e7f-8a9b-0c1d2e3f4a5b", + "key": "mealPlanId", + "value": "d0e1f2a3-b4c5-6d7e-8f9a-0b1c2d3e4f5a", + "type": "string" + }, + { + "id": "f1a2b3c4-d5e6-7f8a-9b0c-1d2e3f4a5b6c", + "key": "mealItemId", + "value": "e1f2a3b4-c5d6-7e8f-9a0b-1c2d3e4f5a6b", + "type": "string" + }, + { + "id": "a2b3c4d5-e6f7-8a9b-0c1d-2e3f4a5b6c7d", + "key": "workoutCategoryId", + "value": "f2a3b4c5-d6e7-8f9a-0b1c-2d3e4f5a6b7c", + "type": "string" + }, + { + "id": "b3c4d5e6-f7a8-9b0c-1d2e-3f4a5b6c7d8e", + "key": "workoutPlanId", + "value": "a3b4c5d6-e7f8-9a0b-1c2d-3e4f5a6b7c8d", + "type": "string" + }, + { + "id": "c4d5e6f7-a8b9-0c1d-2e3f-4a5b6c7d8e9f", + "key": "workoutItemId", + "value": "b4c5d6e7-f8a9-0b1c-2d3e-4f5a6b7c8d9e", + "type": "string" + }, + { + "id": "d5e6f7a8-b9c0-1d2e-3f4a-5b6c7d8e9f0a", + "key": "documentId", + "value": "c5d6e7f8-a9b0-1c2d-3e4f-5a6b7c8d9e0f", + "type": "string" + }, + { + "id": "e6f7a8b9-c0d1-2e3f-4a5b-6c7d8e9f0a1b", + "key": "documentTypeId", + "value": "d6e7f8a9-b0c1-2d3e-4f5a-6b7c8d9e0f1a", + "type": "string" + }, + { + "id": "f7a8b9c0-d1e2-3f4a-5b6c-7d8e9f0a1b2c", + "key": "feedPostId", + "value": "e7f8a9b0-c1d2-3e4f-5a6b-7c8d9e0f1a2b", + "type": "string" + }, + { + "id": "a8b9c0d1-e2f3-4a5b-6c7d-8e9f0a1b2c3d", + "key": "chatId", + "value": "f8a9b0c1-d2e3-4f5a-6b7c-8d9e0f1a2b3c", + "type": "string" + }, + { + "id": "b9c0d1e2-f3a4-5b6c-7d8e-9f0a1b2c3d4e", + "key": "otherUserId", + "value": "a9b0c1d2-e3f4-5a6b-7c8d-9e0f1a2b3c4d", + "type": "string" + }, + { + "id": "ca6c92ae-8d8f-4916-a721-1d7608d970c4", + "key": "accessDeviceId", + "value": "f1f2f3f4-a1a2-b1b2-c1c2-d1d2d3d4d5d6", + "type": "string" + }, + { + "id": "1d7ecf23-79ea-4a07-a6eb-4f9cbf38dc45", + "key": "deviceUserId", + "value": "101", + "type": "string" + }, + { + "id": "2e8fda34-8afb-5b18-b7fc-5a0dcf49ed56", + "key": "pairingCode", + "value": "ABCD2345", + "type": "string" + }, + { + "id": "3f9aeb45-9bac-6c29-c8ad-6b1edf5afe67", + "key": "agentApiKey", + "value": "", + "type": "string" + }, + { + "id": "4a0bfc56-acbd-7d3a-d9be-7c2faf6baf78", + "key": "commandId", + "value": "b1b2b3b4-c1c2-d1d2-e1e2-f1f2f3f4f5f6", + "type": "string" + } + ] +} diff --git a/Fitness_core_BE.csproj b/Fitness_core_BE.csproj new file mode 100644 index 0000000..7d8bdcc --- /dev/null +++ b/Fitness_core_BE.csproj @@ -0,0 +1,34 @@ + + + + net8.0 + enable + enable + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + diff --git a/Helpers/ApiKeyHelper.cs b/Helpers/ApiKeyHelper.cs new file mode 100644 index 0000000..f5cee32 --- /dev/null +++ b/Helpers/ApiKeyHelper.cs @@ -0,0 +1,43 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Fitness_core_BE.Helpers +{ + /// + /// Generates and hashes agent API keys. The raw key is high-entropy random bytes, + /// shown to the agent exactly ONCE at enrollment. Only the SHA-256 hash is persisted + /// in Agent.ApiKeyHash, so a leaked database cannot be used to recover usable keys. + /// + public static class ApiKeyHelper + { + // 32 bytes = 256 bits of entropy. + private const int KeyByteLength = 32; + + /// + /// Generates a new random API key encoded as base64url (URL/header safe, no padding). + /// + public static string GenerateApiKey() + { + byte[] bytes = RandomNumberGenerator.GetBytes(KeyByteLength); + return ToBase64Url(bytes); + } + + /// + /// Hashes a raw API key for storage/lookup. The key is high-entropy so an + /// unsalted SHA-256 is sufficient (and deterministic, so we can index/look it up). + /// + public static string HashApiKey(string apiKey) + { + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(apiKey)); + return Convert.ToBase64String(hash); + } + + private static string ToBase64Url(byte[] bytes) + { + return Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + } +} diff --git a/Helpers/ApiResponseHelper.cs b/Helpers/ApiResponseHelper.cs new file mode 100644 index 0000000..7debcea --- /dev/null +++ b/Helpers/ApiResponseHelper.cs @@ -0,0 +1,11 @@ +using Fitness_core_BE.Models.IOs; +using Microsoft.AspNetCore.Mvc; + +namespace Fitness_core_BE.Helpers +{ + public static class ApiResponseHelper + { + public static ActionResult> Response(T data, int statusCode = 200, string message = "OK") + => new ObjectResult(new ApiResponse { StatusCode = statusCode, Message = message, Data = data }) { StatusCode = statusCode }; + } +} diff --git a/Helpers/AuthOtpResponseParser.cs b/Helpers/AuthOtpResponseParser.cs new file mode 100644 index 0000000..9ea8c2d --- /dev/null +++ b/Helpers/AuthOtpResponseParser.cs @@ -0,0 +1,50 @@ +using Fitness_core_BE.DTOs.Auth; +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Models.IOs; +using System.Text.Json; + +namespace Fitness_core_BE.Helpers +{ + // Parses AuthHex's real /api/alt|/api/user response shapes. The AuthHex gateway wraps + // whatever the underlying handler returns in its own {success,message,data} envelope, so + // authResponse.Data (already unwrapped once by AuthService) can itself be a SECOND envelope: + // { "success": true, "message": "...", "data": { "referenceNumber": "REC...", "expiresAt": "2026-07-03T18:49:38Z", "recoveryType": "..." } } + // referenceNumber is an opaque string, not a Guid, and there is no "expiresInSeconds" field - + // it's an absolute "expiresAt" timestamp that we convert to a countdown. + public static class AuthOtpResponseParser + { + public static SendOtpResult ToSendOtpResult(AuthResponse authResponse, int resendAllowedInSeconds = 60) + { + if (authResponse.Data == null) + throw new BadRequestException("Auth service did not return OTP data."); + + var data = UnwrapToField(DeserializePayload.Deserialize(authResponse.Data), "referenceNumber", "ReferenceNumber"); + var referenceNumber = DataExtractor.GetRequiredStringByKeys(data, "referenceNumber"); + + var expiresAt = DataExtractor.GetOptionalDateTime(data, "expiresAt"); + var expiresInSeconds = expiresAt.HasValue + ? Math.Max(0, (int)(expiresAt.Value.ToUniversalTime() - DateTime.UtcNow).TotalSeconds) + : DataExtractor.GetNullableInt(data, "expiresInSeconds") ?? 600; + + return new SendOtpResult(referenceNumber, expiresInSeconds, resendAllowedInSeconds); + } + + // If the payload doesn't have any of the fields we're after but looks like another + // {success,message,data} envelope, drop down into its "data" and try again. + public static Dictionary UnwrapToField(Dictionary data, params string[] anyOfKeys) + { + for (var depth = 0; depth < 3; depth++) + { + if (anyOfKeys.Any(k => data.ContainsKey(k))) + return data; + + if (!data.TryGetValue("data", out var nested) || nested.ValueKind != JsonValueKind.Object) + return data; + + data = JsonSerializer.Deserialize>(nested.GetRawText())!; + } + + return data; + } + } +} diff --git a/Helpers/BookingViewHtml.cs b/Helpers/BookingViewHtml.cs new file mode 100644 index 0000000..d640c61 --- /dev/null +++ b/Helpers/BookingViewHtml.cs @@ -0,0 +1,88 @@ +using Fitness_core_BE.DTOs.Booking; +using System.Net; + +namespace Fitness_core_BE.Helpers +{ + // Minimal, dependency-free HTML for the public booking confirmation page (BookingController. + // ViewBooking). No admin dashboard or mobile app exists yet for this to link into instead -- + // this is a self-contained page so "share the booking link" already works today. + public static class BookingViewHtml + { + public static string Render(BookingDto booking) + { + var who = booking.IsGuest ? Encode(booking.GuestName) : "Member booking"; + var when = booking.SessionStartTime.HasValue + ? $"{booking.SessionStartTime:dddd, dd MMM yyyy}, {booking.SessionStartTime:h:mm tt} - {booking.SessionEndTime:h:mm tt}" + : "-"; + + var statusColor = booking.Status switch + { + "CheckedIn" => "#16a34a", + "Cancelled" => "#dc2626", + "NoShow" => "#dc2626", + _ => "#2563eb" + }; + + var otpBlock = !string.IsNullOrEmpty(booking.OtpCode) + ? $$""" +
+
Show this code at the door
+
{{Encode(booking.OtpCode)}}
+
+ """ + : booking.OtpVerified + ? "

Checked in -- code already used.

" + : ""; + + return $$""" + + + + + + Booking Confirmation - Vijaya Sport Club + + + +
+

Vijaya Sport Club

+
+
Facility{{Encode(booking.FacilityName)}}
+
Session{{Encode(when)}}
+
Booked for{{who}}
+
Payment{{Encode(booking.PaymentStatus)}}
+
Status{{Encode(booking.Status)}}
+ {{otpBlock}} +
+
+ + + """; + } + + public static string NotFound() => """ + + Booking not found + +

Booking not found

+

This link may have expired or the booking may have been cancelled.

+ + """; + + private static string Encode(string? value) => WebUtility.HtmlEncode(value ?? ""); + } +} diff --git a/Helpers/BusinessLogoHelper.cs b/Helpers/BusinessLogoHelper.cs new file mode 100644 index 0000000..39bbee6 --- /dev/null +++ b/Helpers/BusinessLogoHelper.cs @@ -0,0 +1,23 @@ +namespace Fitness_core_BE.Helpers +{ + // BusinessLogoId (ClientBusiness) holds a relative /Uploads path, not a public URL — there's + // no static-file serving configured for it, so the only way to hand it to a client (web or + // mobile) is to read the bytes server-side and inline them as a base64 data URL. + public static class BusinessLogoHelper + { + public static async Task ReadAsDataUrlAsync(string? businessLogoId) + { + if (string.IsNullOrEmpty(businessLogoId)) return null; + + string uploadsRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..")); + string relativePath = Uri.UnescapeDataString(businessLogoId).Replace("/", Path.DirectorySeparatorChar.ToString()); + string filePath = uploadsRoot + relativePath; + if (!File.Exists(filePath)) return null; + + byte[] bytes = await File.ReadAllBytesAsync(filePath); + string ext = Path.GetExtension(filePath).TrimStart('.').ToLowerInvariant(); + string mime = ext == "png" ? "image/png" : ext == "webp" ? "image/webp" : "image/jpeg"; + return $"data:{mime};base64,{Convert.ToBase64String(bytes)}"; + } + } +} diff --git a/Helpers/CustomerProgressHelper.cs b/Helpers/CustomerProgressHelper.cs new file mode 100644 index 0000000..d67d652 --- /dev/null +++ b/Helpers/CustomerProgressHelper.cs @@ -0,0 +1,514 @@ +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Models; +using Fitness_core_BE.Models.Enums; +using System.Globalization; +using System.Text.Json; + +namespace Fitness_core_BE.Helpers +{ + public static class CustomerProgressHelper + { + public static CustomerTarget BuildCustomerTarget( + Guid cusId, + decimal currentWeight, + decimal currentHeight, + decimal? targetWeight, + string? goal, + decimal timePeriodMonths, + ProgressPrediction predictions, + CustomerTarget? latestTarget, + DateTime createdAt) + { + var timePeriod = timePeriodMonths.ToString("0.##", CultureInfo.InvariantCulture); + + return new CustomerTarget + { + FKCusId = cusId, + CurrentWeight = currentWeight, + CurrentHeight = currentHeight, + TargetWeight = targetWeight, + Goal = goal, + TimePeriod = timePeriod, + PredictedNutritionsJson = JsonSerializer.Serialize(predictions.PredictedNutritions), + PredictedWeightsJson = JsonSerializer.Serialize(predictions.PredictedWeights), + IsChanged = latestTarget != null && + (latestTarget.CurrentWeight != currentWeight || + latestTarget.CurrentHeight != currentHeight || + latestTarget.TargetWeight != targetWeight || + latestTarget.Goal != goal || + latestTarget.TimePeriod != timePeriod), + CreatedAt = createdAt, + IsActive = true + }; + } + + public static object BuildCustomerProgressResponse(CustomerTarget customerTarget) + { + return new + { + ProgressId = customerTarget.CustomerTargetId, + customerTarget.CustomerTargetId, + customerTarget.CurrentWeight, + customerTarget.CurrentHeight, + customerTarget.TargetWeight, + customerTarget.Goal, + customerTarget.TimePeriod, + customerTarget.CreatedAt, + customerTarget.FKCusId, + customerTarget.IsActive, + TargetIsChanged = customerTarget.IsChanged, + TargetCreatedAt = customerTarget.CreatedAt, + PredictedNutritions = DeserializePayload.DeserializeJson>(customerTarget.PredictedNutritionsJson), + PredictedWeights = DeserializePayload.DeserializeJson>(customerTarget.PredictedWeightsJson) + }; + } + + public static ProgressPrediction BuildProgressPredictions( + decimal weight, + decimal targetWeight, + decimal height, + string? goal, + decimal timePeriodMonths, + int age, + string? gender, + string? activityLevel) + { + ValidatePredictionInput(weight, targetWeight, height, timePeriodMonths, age); + + // Normalize user choices before using them in scientific formulas. + var goalType = GetGoalType(goal); + var sex = GetSex(gender); + var activity = GetActivityLevel(activityLevel); + ValidateGoalDirection(goalType, weight, targetWeight); + + // Convert the target period into weeks and calculate the requested weekly pace. + var weeksAvailable = (double)timePeriodMonths * 4.33; + var totalChange = goalType == ProgressGoalType.Maintain + ? 0.0 + : Math.Abs((double)(weight - targetWeight)); + var requiredWeeklyChange = weeksAvailable > 0 + ? totalChange / weeksAvailable + : 0.0; + + // BMR uses Mifflin-St Jeor; TDEE applies the selected activity multiplier. + var bmr = CalculateBmr(weight, height, age, sex); + var tdee = bmr * activity.Multiplier; + var dailyCalorieAdjustment = GetDailyCalorieAdjustment(goalType, requiredWeeklyChange); + var direction = GetDirection(goalType); + + // Apply calorie deficit, surplus, or maintenance based on the goal. + var targetCalories = goalType switch + { + ProgressGoalType.Loss => tdee - dailyCalorieAdjustment, + ProgressGoalType.Gain => tdee + dailyCalorieAdjustment, + _ => tdee + }; + + // Estimate weekly weight change using the 7700 kcal per kg approximation. + var predictedWeeklyChange = dailyCalorieAdjustment <= 0 + ? 0.0 + : (dailyCalorieAdjustment * 7.0) / 7700.0; + + // Protein depends on goal; fat is 25% of calories; carbs use remaining calories. + var proteinPerKg = GetProteinFactor(goalType, goal); + var protein = Math.Round((double)weight * proteinPerKg, 2); + var fat = Math.Round((targetCalories * 0.25) / 9.0, 2); + var remainingCalories = targetCalories - (protein * 4.0 + fat * 9.0); + var carbs = Math.Round(Math.Max(remainingCalories / 4.0, 0.0), 2); + var bmi = Math.Round(CalculateBmi(weight, height), 2); + var difficulty = DetermineDifficulty(requiredWeeklyChange); + + // Build a week-by-week projection and prevent values from passing the target weight. + var totalWeeks = Math.Max(1, (int)Math.Ceiling(weeksAvailable)); + var predictedWeights = new Dictionary(); + + for (var week = 0; week <= totalWeeks; week++) + { + var predictedWeight = (double)weight + (direction * predictedWeeklyChange * week); + predictedWeight = KeepPredictionInsideTarget(goalType, predictedWeight, (double)targetWeight); + predictedWeights[$"w{week + 1}"] = (decimal)Math.Round(predictedWeight, 1); + } + + var signedCalorieAdjustment = direction * dailyCalorieAdjustment; + + // Keep output as strings because this JSON is stored and returned directly to clients. + var predictedNutritions = new Dictionary + { + ["calories"] = $"{FormatNumber(targetCalories)} kcal/day", + ["targetCalories"] = $"{FormatNumber(targetCalories)} kcal/day", + ["bmr"] = $"{FormatNumber(bmr)} kcal/day", + ["tdee"] = $"{FormatNumber(tdee)} kcal/day", + ["protein"] = $"{FormatNumber(protein)}g", + ["fat"] = $"{FormatNumber(fat)}g", + ["carbs"] = $"{FormatNumber(carbs)}g", + ["bmi"] = FormatNumber(bmi), + ["activityLevel"] = activity.Label, + ["activityMultiplier"] = FormatNumber(activity.Multiplier), + ["dailyCalorieChange"] = $"{FormatSignedNumber(signedCalorieAdjustment)} kcal/day", + ["requiredWeeklyWeightChange"] = $"{FormatNumber(requiredWeeklyChange)} kg/week", + ["predictedWeeklyWeightChange"] = $"{FormatNumber(predictedWeeklyChange)} kg/week", + ["difficulty"] = difficulty, + ["intensity"] = difficulty + }; + + return new ProgressPrediction + { + PredictedNutritions = predictedNutritions, + PredictedWeights = predictedWeights, + Intensity = difficulty + }; + } + + // Re-projects just the weight trajectory (not the nutrition targets, which need + // activityLevel — a one-time input never persisted on CustomerTarget) from the + // customer's latest logged weight to their existing target, over however much of + // the original plan window is left. Called whenever a weight update changes + // CurrentWeight, so the "goal" line reflects real progress instead of staying + // fixed to the weight recorded at onboarding. + public static Dictionary RebuildPredictedWeights( + decimal currentWeight, + decimal targetWeight, + string? goal, + double remainingWeeks) + { + var goalType = GetGoalType(goal); + var direction = GetDirection(goalType); + + var totalChange = goalType == ProgressGoalType.Maintain + ? 0.0 + : Math.Abs((double)(currentWeight - targetWeight)); + var weeklyChange = remainingWeeks > 0 ? totalChange / remainingWeeks : 0.0; + + var totalWeeks = Math.Max(1, (int)Math.Ceiling(remainingWeeks)); + var predictedWeights = new Dictionary(); + for (var week = 0; week <= totalWeeks; week++) + { + var predictedWeight = (double)currentWeight + (direction * weeklyChange * week); + predictedWeight = KeepPredictionInsideTarget(goalType, predictedWeight, (double)targetWeight); + predictedWeights[$"w{week + 1}"] = (decimal)Math.Round(predictedWeight, 1); + } + return predictedWeights; + } + + public static int GetProgressPredictionAge(Dictionary data, Customer customer, DateTime asOf) + { + var age = DataExtractor.GetNullableInt(data, "age"); + + if (age.HasValue) + { + if (age.Value <= 0) + throw new BadRequestException("Field 'age' must be greater than zero."); + + return age.Value; + } + + if (!customer.BoD.HasValue) + throw new BadRequestException("Field 'age' is required when customer date of birth is not available."); + + var calculatedAge = CalculateAge(customer.BoD.Value, asOf); + if (calculatedAge <= 0) + throw new BadRequestException( + $"The date of birth on this profile ({customer.BoD.Value:yyyy-MM-dd}) is today or in " + + "the future, so an age can't be worked out. Update the date of birth on the profile, " + + "or send an explicit 'age' with this request."); + + return calculatedAge; + } + + public static string GetProgressPredictionGender(Dictionary data, Customer customer) + { + var gender = DataExtractor.GetOptionalStringByKeys(data, "gender", "Gender") ?? customer.Gender; + if (!string.IsNullOrWhiteSpace(gender)) + return gender; + + throw new BadRequestException("Field 'gender' is required when customer gender is not available."); + } + + public static int CalculateAge(DateOnly birthDate, DateTime asOf) + { + var today = DateOnly.FromDateTime(asOf); + var age = today.Year - birthDate.Year; + + if (birthDate > today.AddYears(-age)) + age--; + + return age; + } + + public static bool IsMaintainGoal(string goal) + { + var normalizedGoal = NormalizeText(goal); + return normalizedGoal is "maintain" or "maintain weight"; + } + + private static void ValidatePredictionInput( + decimal weight, + decimal targetWeight, + decimal height, + decimal timePeriodMonths, + int age) + { + if (weight <= 0) + throw new ArgumentException("Field 'currentWeight' must be greater than zero."); + + if (targetWeight <= 0) + throw new ArgumentException("Field 'targetWeight' must be greater than zero."); + + if (height <= 0) + throw new ArgumentException("Field 'currentHeight' must be greater than zero."); + + if (timePeriodMonths <= 0) + throw new ArgumentException("Field 'timePeriod' must be greater than zero."); + + if (age <= 0) + throw new ArgumentException("Field 'age' must be greater than zero."); + } + + private static ProgressGoalType GetGoalType(string? goal) + { + var normalizedGoal = NormalizeText(goal); + + return normalizedGoal switch + { + "lose weight" => ProgressGoalType.Loss, + "gain muscle" => ProgressGoalType.Gain, + "body recomposition" => ProgressGoalType.Maintain, + "strength" => ProgressGoalType.Strength, + "endurance" => ProgressGoalType.Endurance, + "athletic performance" => ProgressGoalType.AthleticPerformance, + "stay fit" => ProgressGoalType.StayFit, + "flexibility" => ProgressGoalType.StayFit, + "beginner" => ProgressGoalType.Beginner, + + _ => throw new ArgumentException("Field 'goal' must be one of these") + }; + } + + private static Sex GetSex(string? gender) + { + var normalizedGender = NormalizeText(gender); + + return normalizedGender switch + { + "male" or "m" => Sex.Male, + "female" or "f" => Sex.Female, + _ => throw new ArgumentException("Field 'gender' must be either 'Male' or 'Female' for BMR calculation.") + }; + } + + private static ActivityLevelInfo GetActivityLevel(string? activityLevel) + { + var normalizedActivityLevel = NormalizeText(activityLevel); + + return normalizedActivityLevel switch + { + "low" => new ActivityLevelInfo("Lightly Active", 1.375), + "moderate" => new ActivityLevelInfo("Moderately Active", 1.55), + "high" => new ActivityLevelInfo("Very Active", 1.725), + _ => throw new ArgumentException("Field 'activityLevel' must be one of: Lightly Active, Moderately Active, Very Active") + }; + } + + private static void ValidateGoalDirection(ProgressGoalType goalType, decimal weight, decimal targetWeight) + { + if (goalType == ProgressGoalType.Loss && targetWeight > weight) + throw new ArgumentException("Field 'targetWeight' must be less than or equal to current weight for weight loss or fat loss goals."); + + if (goalType == ProgressGoalType.Gain && targetWeight < weight) + throw new ArgumentException("Field 'targetWeight' must be greater than or equal to current weight for weight gain or muscle gain goals."); + } + + private static double CalculateBmr(decimal weight, decimal height, int age, Sex sex) + { + var baseBmr = (10.0 * (double)weight) + (6.25 * (double)height) - (5.0 * age); + return sex == Sex.Male + ? baseBmr + 5.0 + : baseBmr - 161.0; + } + + private static double CalculateBmi(decimal weight, decimal height) + { + var heightMeters = (double)height / 100.0; + return (double)weight / (heightMeters * heightMeters); + } + + private static double GetDailyCalorieAdjustment(ProgressGoalType goalType, double requiredWeeklyChange) + { + var requestedDailyChange = (requiredWeeklyChange * 7700.0) / 7.0; + + return goalType switch + { + ProgressGoalType.Loss => Math.Min(requestedDailyChange, 700.0), + ProgressGoalType.Gain => Math.Min(requestedDailyChange, 400.0), + _ => 0.0 + }; + } + + private static double GetProteinFactor(ProgressGoalType goalType, string? goal) + { + var normalizedGoal = NormalizeText(goal); + + if (normalizedGoal == "muscle gain") + return 2.0; + + return goalType switch + { + ProgressGoalType.Loss => 2.0, + ProgressGoalType.Gain => 1.8, + _ => 1.6 + }; + } + + private static double GetDirection(ProgressGoalType goalType) + { + return goalType switch + { + ProgressGoalType.Loss => -1.0, + ProgressGoalType.Gain => 1.0, + _ => 0.0 + }; + } + + private static double KeepPredictionInsideTarget(ProgressGoalType goalType, double predictedWeight, double targetWeight) + { + return goalType switch + { + ProgressGoalType.Loss => Math.Max(predictedWeight, targetWeight), + ProgressGoalType.Gain => Math.Min(predictedWeight, targetWeight), + _ => predictedWeight + }; + } + + private static string DetermineDifficulty(double requiredWeeklyChange) + { + return requiredWeeklyChange switch + { + <= 0.5 => "Easy", + <= 0.75 => "Moderate", + <= 1.0 => "Hard", + _ => "Extreme / Unsafe" + }; + } + + private static string NormalizeText(string? value) + { + var normalized = (value ?? string.Empty) + .Trim() + .ToLowerInvariant() + .Replace("-", " ") + .Replace("_", " "); + + return string.Join(' ', normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + } + + private static string FormatNumber(double value) + { + return value.ToString("0.##", CultureInfo.InvariantCulture); + } + + private static string FormatSignedNumber(double value) + { + var sign = value > 0 ? "+" : string.Empty; + return sign + FormatNumber(value); + } + + private readonly record struct ActivityLevelInfo(string Label, double Multiplier); + + public static string GetPredictedNutritionsJson(Dictionary data) + { + var nutritions = data.TryGetValue("predictedNutritions", out var element) + ? BuildNutritionDictionary(element) + : BuildNutritionDictionary(data); + + return JsonSerializer.Serialize(nutritions); + } + + private static Dictionary BuildNutritionDictionary(Dictionary data) + { + return new Dictionary + { + ["calories"] = GetRequiredNutritionValueByKeys(data, "calories"), + ["protein"] = GetRequiredNutritionValue(data, "protein"), + ["fat"] = GetRequiredNutritionValue(data, "fat"), + ["carbs"] = GetRequiredNutritionValue(data, "carbs"), + ["intensity"] = GetRequiredNutritionValue(data, "intensity") + }; + } + + private static Dictionary BuildNutritionDictionary(JsonElement element) + { + if (element.ValueKind != JsonValueKind.Object) + throw new BadRequestException("Field 'predictedNutritions' must be a JSON object."); + + return new Dictionary + { + ["calories"] = GetRequiredNutritionValueByKeys(element, "calories"), + ["protein"] = GetRequiredNutritionValue(element, "protein"), + ["fat"] = GetRequiredNutritionValue(element, "fat"), + ["carbs"] = GetRequiredNutritionValue(element, "carbs"), + ["intensity"] = GetRequiredNutritionValue(element, "intensity") + }; + } + + private static string GetRequiredNutritionValueByKeys(Dictionary data, params string[] keys) + { + foreach (var key in keys) + { + if (data.TryGetValue(key, out var element)) + return ReadNutritionValue(element, key); + } + + throw new BadRequestException($"Missing required field '{keys[0]}'."); + } + + private static string GetRequiredNutritionValueByKeys(JsonElement source, params string[] keys) + { + foreach (var key in keys) + { + if (source.TryGetProperty(key, out var element)) + return ReadNutritionValue(element, key); + } + + throw new BadRequestException($"Missing required field '{keys[0]}'."); + } + + private static string GetRequiredNutritionValue(Dictionary data, string key) + { + if (!data.TryGetValue(key, out var element)) + throw new BadRequestException($"Missing required field '{key}'."); + + return ReadNutritionValue(element, key); + } + + private static string GetRequiredNutritionValue(JsonElement source, string key) + { + if (!source.TryGetProperty(key, out var element)) + throw new BadRequestException($"Missing required field '{key}'."); + + return ReadNutritionValue(element, key); + } + + private static string ReadNutritionValue(JsonElement element, string key) + { + if (element.ValueKind == JsonValueKind.String) + { + var value = element.GetString(); + if (!string.IsNullOrWhiteSpace(value)) + return value; + } + + if (element.ValueKind == JsonValueKind.Number) + return element.GetRawText(); + + throw new BadRequestException($"Field '{key}' must be a non-empty string or number."); + } + + } + + public sealed class ProgressPrediction + { + public Dictionary PredictedNutritions { get; set; } = new(); + public Dictionary PredictedWeights { get; set; } = new(); + public string Intensity { get; set; } = "Moderate"; + } +} diff --git a/Helpers/DataExtractor.cs b/Helpers/DataExtractor.cs new file mode 100644 index 0000000..70a53a7 --- /dev/null +++ b/Helpers/DataExtractor.cs @@ -0,0 +1,407 @@ +using Fitness_core_BE.DTOs.Auth; +using System.Text.Json; +using Fitness_core_BE.Exceptions; + +namespace Fitness_core_BE.Helpers +{ + public class DataExtractor + { + public static string GetRequiredString( + Dictionary data, + string key) + { + if (!data.TryGetValue(key, out var element)) + throw new BadRequestException($"Missing required field '{key}'."); + + if (element.ValueKind != JsonValueKind.String) + throw new BadRequestException($"Field '{key}' must be a string."); + + var value = element.GetString(); + + if (string.IsNullOrWhiteSpace(value)) + return ""; + //throw new BadRequestException($"Field '{key}' cannot be empty."); + + + return value; + } + + public static string? GetOptionalString( + Dictionary data, + string key) + { + if (!data.TryGetValue(key, out var element) || element.ValueKind == JsonValueKind.Null) + return null; + + if (element.ValueKind == JsonValueKind.String) + { + var value = element.GetString(); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + if (element.ValueKind == JsonValueKind.Number) + return element.GetRawText(); + + throw new ArgumentException($"Field '{key}' must be a string."); + } + + public static string? GetOptionalStringByKeys( + Dictionary data, + params string[] keys) + { + foreach (var key in keys) + { + if (data.ContainsKey(key)) + return GetOptionalString(data, key); + } + + return null; + } + + public static string GetRequiredStringByKeys( + Dictionary data, + params string[] keys) + { + var value = GetOptionalStringByKeys(data, keys); + if (!string.IsNullOrWhiteSpace(value)) + return value; + + throw new BadRequestException($"Missing required field '{keys[0]}'."); + } + + // ===================== INT ===================== + + public static int GetRequiredInt( + Dictionary data, + string key) + { + if (!data.TryGetValue(key, out var element)) + throw new BadRequestException($"Missing required field '{key}'."); + + if (element.TryGetInt32(out var value)) + return value; + + if (element.ValueKind == JsonValueKind.String && + int.TryParse(element.GetString(), out value)) + { + return value; + } + + throw new BadRequestException($"Field '{key}' must be a valid integer."); + } + + public static int? GetNullableInt( + Dictionary data, + string key) + { + if (!data.TryGetValue(key, out var element) || element.ValueKind == JsonValueKind.Null) + return null; + + if (element.TryGetInt32(out var value)) + return value; + + if (element.ValueKind == JsonValueKind.String && + int.TryParse(element.GetString(), out value)) + { + return value; + } + + throw new BadRequestException($"Field '{key}' must be a valid integer."); + } + + // ===================== FLOAT ===================== + + public static float GetRequiredFloat( + Dictionary data, + string key) + { + if (!data.TryGetValue(key, out var element)) + throw new BadRequestException($"Missing required field '{key}'."); + + if (element.TryGetSingle(out var value)) + return value; + + // Allow numeric strings: "12.5" + if (element.ValueKind == JsonValueKind.String && + float.TryParse(element.GetString(), out value)) + { + return value; + } + + throw new BadRequestException($"Field '{key}' must be a valid float."); + } + + // ===================== DECIMAL ===================== + public static decimal GetRequiredDecimal( + Dictionary data, + string key) + { + if (!data.TryGetValue(key, out var element)) + throw new ArgumentException($"Missing required field '{key}'."); + if (element.TryGetDecimal(out var value)) + return value; + // Allow numeric strings: "12.5" + if (element.ValueKind == JsonValueKind.String && + decimal.TryParse(element.GetString(), out value)) + { + return value; + } + throw new ArgumentException($"Field '{key}' must be a valid decimal."); + } + + public static decimal? GetOptionalDecimal( + Dictionary data, + string key) + { + if (!data.TryGetValue(key, out var element) || element.ValueKind == JsonValueKind.Null) + return null; + + if (element.TryGetDecimal(out var value)) + return value; + + if (element.ValueKind == JsonValueKind.String && + decimal.TryParse(element.GetString(), out value)) + { + return value; + } + + throw new ArgumentException($"Field '{key}' must be a valid decimal."); + } + + public static decimal? GetOptionalDecimalByKeys( + Dictionary data, + params string[] keys) + { + foreach (var key in keys) + { + if (data.ContainsKey(key)) + return GetOptionalDecimal(data, key); + } + + return null; + } + + // ===================== BOOLEAN ===================== + + public static bool GetRequiredBool( + Dictionary data, + string key) + { + if (!data.TryGetValue(key, out var element)) + throw new BadRequestException($"Missing required field '{key}'."); + + if (element.ValueKind == JsonValueKind.True) return true; + if (element.ValueKind == JsonValueKind.False) return false; + + // Allow string values: "true" / "false" + if (element.ValueKind == JsonValueKind.String && + bool.TryParse(element.GetString(), out var value)) + { + return value; + } + + throw new BadRequestException($"Field '{key}' must be a valid boolean."); + } + + public static bool? GetOptionalBool( + Dictionary data, + string key) + { + if (!data.TryGetValue(key, out var element) || element.ValueKind == JsonValueKind.Null) + return null; + + if (element.ValueKind == JsonValueKind.True) return true; + if (element.ValueKind == JsonValueKind.False) return false; + + if (element.ValueKind == JsonValueKind.String && + bool.TryParse(element.GetString(), out var value)) + { + return value; + } + + throw new BadRequestException($"Field '{key}' must be a valid boolean."); + } + + // ===================== GUID ===================== + + public static Guid GetRequiredGuid( + Dictionary data, + string key) + { + var value = GetRequiredString(data, key); + + if (!Guid.TryParse(value, out var guid)) + throw new BadRequestException($"Field '{key}' must be a valid GUID."); + + return guid; + } + + /// A JSON array of GUID strings, or null if the key is absent/null. An + /// empty array is returned as an empty list (distinct from "field not sent"), + /// which matters for "clear this plan's places" updates. + public static List? GetOptionalGuidList( + Dictionary data, + string key) + { + if (!data.TryGetValue(key, out var element) || element.ValueKind == JsonValueKind.Null) + return null; + + if (element.ValueKind != JsonValueKind.Array) + throw new BadRequestException($"Field '{key}' must be an array."); + + var result = new List(); + foreach (var item in element.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.String || !Guid.TryParse(item.GetString(), out var guid)) + throw new BadRequestException($"Field '{key}' must contain only GUID strings."); + result.Add(guid); + } + + return result; + } + + // ===================== DATETIME ===================== + + public static DateTime GetRequiredDateTime( + Dictionary data, + string key) + { + if (!data.TryGetValue(key, out var element)) + throw new BadRequestException($"Missing required field '{key}'."); + + if (element.ValueKind == JsonValueKind.Null) + throw new BadRequestException($"Field '{key}' cannot be null."); + + // ISO 8601 string + if (element.ValueKind == JsonValueKind.String && + DateTime.TryParse(element.GetString(), out var date)) + { + return date; + } + + // Native JSON DateTime + if (element.TryGetDateTime(out date)) + { + return date; + } + + throw new BadRequestException($"Field '{key}' must be a valid DateTime."); + } + + public static DateTime? GetOptionalDateTime( + Dictionary data, + string key) + { + if (!data.TryGetValue(key, out var element) || element.ValueKind == JsonValueKind.Null) + return null; + + if (element.ValueKind == JsonValueKind.String && + DateTime.TryParse(element.GetString(), out var date)) + { + return date; + } + + if (element.TryGetDateTime(out date)) + return date; + + throw new ArgumentException($"Field '{key}' must be a valid DateTime."); + } + + // ===================== DATEONLY ===================== + + public static DateOnly GetRequiredDateOnly( + Dictionary data, + string key) + { + if (!data.TryGetValue(key, out var element)) + throw new BadRequestException($"Missing required field '{key}'."); + + if (element.ValueKind == JsonValueKind.Null) + throw new BadRequestException($"Field '{key}' cannot be null."); + + DateTime date; + + // ISO 8601 string (e.g., "2025-02-25") + if (element.ValueKind == JsonValueKind.String && + DateTime.TryParse(element.GetString(), out date)) + { + return DateOnly.FromDateTime(date); + } + + + if (element.TryGetDateTime(out date)) + { + return DateOnly.FromDateTime(date); + } + + throw new BadRequestException($"Field '{key}' must be a valid date."); + } + + /// + /// A date of birth, rejected if it is today or in the future. Progress predictions + /// divide by the member's age, so a zero age has to be stopped at the write rather + /// than surfacing later as a failure in an unrelated screen. + /// + public static DateOnly GetRequiredDateOfBirth( + Dictionary data, + string key) + { + var birthDate = GetRequiredDateOnly(data, key); + + if (birthDate >= DateOnly.FromDateTime(DateTime.UtcNow)) + throw new BadRequestException( + $"Field '{key}' must be a date in the past."); + + return birthDate; + } + + /// + /// Same as GetRequiredDateOfBirth, but missing/null/blank is allowed and returns null + /// instead of throwing -- a birthday isn't needed to run a subscription, so gyms that + /// don't collect it at signup shouldn't be blocked from adding a member. + /// + public static DateOnly? GetOptionalDateOfBirth( + Dictionary data, + string key) + { + if (!data.TryGetValue(key, out var element) || element.ValueKind == JsonValueKind.Null) + return null; + + if (element.ValueKind == JsonValueKind.String && string.IsNullOrWhiteSpace(element.GetString())) + return null; + + return GetRequiredDateOfBirth(data, key); + } + + // ===================== USER ID FROM HttpContext ===================== + public static Guid GetUserIdFromContext(HttpContext httpContext) + { + + if (httpContext == null) throw new ArgumentNullException(nameof(httpContext)); + + var userIdFromContext = httpContext.Items["UserId"]?.ToString() + ?? httpContext.User?.FindFirst("UserId")?.Value; + + if (string.IsNullOrWhiteSpace(userIdFromContext) || !Guid.TryParse(userIdFromContext, out var userId)) + { + throw new UnauthorizedAccessException("User is not authenticated. Please login and send cookie."); + } + + return userId; + } + + //============================AuthResponce EXtractor============ + public static AuthResponse GetRequiredAuthResponse(Dictionary data) + { + AuthResponse authResponse = new AuthResponse + { + StatusCode = GetRequiredInt(data, "StatusCode"), + Success = GetRequiredBool(data, "success"), + Message = GetRequiredString(data, "message"), + Data = data + }; + + return authResponse; + } + } +} diff --git a/Helpers/DeserializePayload.cs b/Helpers/DeserializePayload.cs new file mode 100644 index 0000000..7c54dee --- /dev/null +++ b/Helpers/DeserializePayload.cs @@ -0,0 +1,31 @@ +using System.Text.Json; +using Fitness_core_BE.Exceptions; + +namespace Fitness_core_BE.Helpers +{ + public static class DeserializePayload + { + public static Dictionary Deserialize(object payload) + { + ArgumentNullException.ThrowIfNull(payload); + var json = JsonSerializer.Serialize(payload); + return JsonSerializer.Deserialize>(json) + ?? throw new BadRequestException("Failed to deserialize payload."); + } + + public static T? DeserializeJson(string? json) where T : class + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + try + { + return JsonSerializer.Deserialize(json); + } + catch + { + return null; + } + } + } +} diff --git a/Helpers/EncryptionHelper.cs b/Helpers/EncryptionHelper.cs new file mode 100644 index 0000000..cb0991b --- /dev/null +++ b/Helpers/EncryptionHelper.cs @@ -0,0 +1,35 @@ +using System.Security.Cryptography; +using System.Text; + +public static class EncryptionHelper +{ + private static readonly byte[] Key = Convert.FromBase64String("6pY8vR2Nf+Xz9WkLm4TqQzB7vA9uS1nE5jR3hG6fM8o="); + + public static string Encrypt(string plainText) + { + using Aes aes = Aes.Create(); + aes.Key = Key; + aes.GenerateIV(); + + using var encryptor = aes.CreateEncryptor(); + byte[] plainBytes = Encoding.UTF8.GetBytes(plainText); + byte[] cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length); + + return Convert.ToBase64String(aes.IV.Concat(cipherBytes).ToArray()); + } + + public static string Decrypt(string combinedText) + { + byte[] combinedBytes = Convert.FromBase64String(combinedText); + using Aes aes = Aes.Create(); + aes.Key = Key; + + byte[] iv = combinedBytes.Take(16).ToArray(); + byte[] cipherBytes = combinedBytes.Skip(16).ToArray(); + + using var decryptor = aes.CreateDecryptor(aes.Key, iv); + byte[] plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length); + + return Encoding.UTF8.GetString(plainBytes); + } +} \ No newline at end of file diff --git a/Helpers/FacePhotoValidator.cs b/Helpers/FacePhotoValidator.cs new file mode 100644 index 0000000..e787511 --- /dev/null +++ b/Helpers/FacePhotoValidator.cs @@ -0,0 +1,157 @@ +using Fitness_core_BE.Exceptions; + +namespace Fitness_core_BE.Helpers +{ + /// + /// Server-side guard for face photos headed to a Hikvision face library. + /// + /// The browser already resizes and re-encodes before upload, so this does not decode or + /// re-encode -- it validates. That keeps the project free of an imaging package (and of + /// ImageSharp's commercial-licence requirement) while still refusing anything a terminal + /// would reject, including calls that bypass the web client entirely. + /// + /// Limits follow the face-library constraints rather than the ISAPI transport ceiling: + /// a 2.5 MB photo uploads happily and then fails enrolment, which is the worst outcome + /// because it looks like it worked. + /// + public static class FacePhotoValidator + { + /// Face-library ceiling. Well under the ~3 MB ISAPI transport limit. + public const int MaxBytes = 200 * 1024; + + /// Below this the image is too small to hold a usable face. + public const int MinDimension = 160; + + /// Terminals reject oversized frames outright. + public const int MaxDimension = 1920; + + public sealed record FacePhotoInfo(byte[] Bytes, int Width, int Height); + + /// + /// Decodes the base64 payload (with or without a data: prefix) and enforces the + /// Hikvision face-library rules. Throws BadRequestException with a message the + /// front end can show directly. + /// + public static FacePhotoInfo ValidateBase64(string? base64) + { + if (string.IsNullOrWhiteSpace(base64)) + throw new BadRequestException("A face photo is required."); + + // Accept "data:image/jpeg;base64,..." as well as a bare payload. + var payload = base64; + var comma = payload.IndexOf(','); + if (payload.StartsWith("data:", StringComparison.OrdinalIgnoreCase) && comma > 0) + payload = payload[(comma + 1)..]; + + byte[] bytes; + try + { + bytes = Convert.FromBase64String(payload.Trim()); + } + catch (FormatException) + { + throw new BadRequestException("The face photo is not valid base64 data."); + } + + if (bytes.Length == 0) + throw new BadRequestException("The face photo is empty."); + + if (bytes.Length > MaxBytes) + throw new BadRequestException( + $"The face photo is {bytes.Length / 1024} KB. The access terminal accepts " + + $"at most {MaxBytes / 1024} KB -- retake or choose a smaller photo."); + + // Trust the bytes, not the file name: JPEG starts FF D8 FF and ends FF D9. + if (!IsJpeg(bytes)) + throw new BadRequestException( + "The face photo must be a JPEG image. PNG, HEIC and other formats are " + + "not accepted by the access terminal."); + + var size = ReadJpegSize(bytes) + ?? throw new BadRequestException("The face photo is not a readable JPEG image."); + + var (width, height) = size; + + if (width < MinDimension || height < MinDimension) + throw new BadRequestException( + $"The face photo is {width}x{height}. It must be at least " + + $"{MinDimension}x{MinDimension} pixels."); + + if (width > MaxDimension || height > MaxDimension) + throw new BadRequestException( + $"The face photo is {width}x{height}. It must be no larger than " + + $"{MaxDimension}x{MaxDimension} pixels."); + + return new FacePhotoInfo(bytes, width, height); + } + + private static bool IsJpeg(byte[] bytes) => + bytes.Length >= 4 && + bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF; + + /// + /// Walks the JPEG marker segments to the start-of-frame, which carries the real + /// dimensions. Returns null if the stream is truncated or malformed. + /// + private static (int Width, int Height)? ReadJpegSize(byte[] bytes) + { + // Skip the SOI marker. + var i = 2; + + while (i + 3 < bytes.Length) + { + if (bytes[i] != 0xFF) + { + i++; // Resynchronise past fill bytes / entropy data. + continue; + } + + var marker = bytes[i + 1]; + + // Standalone markers carry no length payload. + if (marker == 0xFF) { i++; continue; } + if (marker == 0xD8 || marker == 0x01 || (marker >= 0xD0 && marker <= 0xD7)) + { + i += 2; + continue; + } + + // Start of scan -- image data begins, no SOF found. + if (marker == 0xDA || marker == 0xD9) + return null; + + if (i + 3 >= bytes.Length) + return null; + + var segmentLength = (bytes[i + 2] << 8) | bytes[i + 3]; + if (segmentLength < 2) + return null; + + // SOF0/1/2/3/5/6/7/9/10/11/13/14/15 hold the frame header. DHT (C4), JPGA (C8) + // and DAC (CC) sit in the same range but are not frame headers. + var isStartOfFrame = + marker >= 0xC0 && marker <= 0xCF && + marker != 0xC4 && marker != 0xC8 && marker != 0xCC; + + if (isStartOfFrame) + { + // [len:2][precision:1][height:2][width:2] + if (i + 9 >= bytes.Length) + return null; + + var height = (bytes[i + 5] << 8) | bytes[i + 6]; + var width = (bytes[i + 7] << 8) | bytes[i + 8]; + + if (width <= 0 || height <= 0) + return null; + + return (width, height); + } + + i += 2 + segmentLength; + } + + return null; + } + } +} diff --git a/Helpers/GlobalExceptionMiddleware.cs b/Helpers/GlobalExceptionMiddleware.cs new file mode 100644 index 0000000..400de9d --- /dev/null +++ b/Helpers/GlobalExceptionMiddleware.cs @@ -0,0 +1,59 @@ +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Models.IOs; + +namespace Fitness_core_BE.Helpers +{ + // The app has no central exception->HTTP mapping today -- an unhandled NotFoundException/ + // BadRequestException/etc. (or the equivalent BCL KeyNotFoundException/ArgumentException + // many services throw) 500s with a raw stack trace instead of the ApiResponse shape every + // other error path returns. AgentController.cs already hand-rolls this exact mapping + // per-action (see its catch blocks); this middleware applies the same mapping once, for + // every controller, without having to add a try/catch to each action. + public class GlobalExceptionMiddleware + { + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public GlobalExceptionMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (Exception ex) + { + var (statusCode, message) = ex switch + { + NotFoundException or KeyNotFoundException => (404, ex.Message), + BadRequestException or ArgumentException => (400, ex.Message), + UnauthorizedException => (401, ex.Message), + ForbiddenException => (403, ex.Message), + ServiceUnavailableException => (503, ex.Message), + _ => (500, "An unexpected error occurred."), + }; + + if (statusCode == 500) + _logger.LogError(ex, "Unhandled exception for {Method} {Path}", context.Request.Method, context.Request.Path); + + if (context.Response.HasStarted) + throw; + + context.Response.Clear(); + context.Response.StatusCode = statusCode; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsJsonAsync(new ApiResponse + { + StatusCode = statusCode, + Message = message, + Data = null! + }); + } + } + } +} diff --git a/Helpers/GymFacilities.cs b/Helpers/GymFacilities.cs new file mode 100644 index 0000000..b7f31d2 --- /dev/null +++ b/Helpers/GymFacilities.cs @@ -0,0 +1,49 @@ +using System.Text.Json; + +namespace Fitness_core_BE.Helpers +{ + public record EquipmentCount(string Name, int Count); + + public class GymFacilitiesData + { + public bool HasAc { get; set; } + public List Equipment { get; set; } = new(); + } + + public static class GymFacilities + { + private static readonly JsonSerializerOptions Opts = new(JsonSerializerDefaults.Web); + + public static GymFacilitiesData Parse(string? facilities) + { + if (string.IsNullOrWhiteSpace(facilities)) return new GymFacilitiesData(); + try + { + return JsonSerializer.Deserialize(facilities, Opts) ?? new GymFacilitiesData(); + } + catch (JsonException) + { + // Older records may hold plain-text facilities — treat as no structured data. + return new GymFacilitiesData(); + } + } + + public static string Serialize(bool hasAc, IEnumerable? equipment) + { + var data = new GymFacilitiesData + { + HasAc = hasAc, + Equipment = (equipment ?? Enumerable.Empty()) + .Where(e => !string.IsNullOrWhiteSpace(e.Name)) + .GroupBy(e => e.Name.Trim(), StringComparer.OrdinalIgnoreCase) + .Select(group => new EquipmentCount( + group.First().Name.Trim(), + (int)Math.Min( + group.Sum(item => Math.Max(0L, item.Count)), + int.MaxValue))) + .ToList() + }; + return JsonSerializer.Serialize(data, Opts); + } + } +} diff --git a/Helpers/InvoicePaymentTokenHelper.cs b/Helpers/InvoicePaymentTokenHelper.cs new file mode 100644 index 0000000..ce15e62 --- /dev/null +++ b/Helpers/InvoicePaymentTokenHelper.cs @@ -0,0 +1,72 @@ +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace Fitness_core_BE.Helpers +{ + // Mints/validates the short-lived token used by the public "pay by link" page + // (GET/POST /api/client/pay/{token}). Same self-contained HMAC approach as + // RequestAccessTokenHelper, just scoped to a single CustomerInvoiceId. + public class InvoicePaymentTokenHelper + { + private readonly string _signingKey; + private readonly int _expiresInDays; + + public InvoicePaymentTokenHelper(IConfiguration configuration) + { + _signingKey = configuration["InvoicePaymentToken:SigningKey"] + ?? throw new InvalidOperationException("InvoicePaymentToken:SigningKey is not configured."); + _expiresInDays = int.TryParse(configuration["InvoicePaymentToken:ExpiresInDays"], out var d) ? d : 7; + } + + public string Generate(Guid customerInvoiceId) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_signingKey)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim("CustomerInvoiceId", customerInvoiceId.ToString()), + new Claim("Purpose", "InvoicePayment") + }; + + var expires = DateTime.UtcNow.AddDays(_expiresInDays); + var token = new JwtSecurityToken( + claims: claims, + expires: expires, + signingCredentials: creds); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public Guid? ValidateAndGetInvoiceId(string token) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_signingKey)); + var handler = new JwtSecurityTokenHandler(); + + try + { + var principal = handler.ValidateToken(token, new TokenValidationParameters + { + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKey = key, + ClockSkew = TimeSpan.FromMinutes(2) + }, out _); + + if (principal.FindFirst("Purpose")?.Value != "InvoicePayment") + return null; + + var idClaim = principal.FindFirst("CustomerInvoiceId")?.Value; + return Guid.TryParse(idClaim, out var id) ? id : null; + } + catch + { + return null; + } + } + } +} diff --git a/Helpers/JwtMiddleware.cs b/Helpers/JwtMiddleware.cs new file mode 100644 index 0000000..6361afa --- /dev/null +++ b/Helpers/JwtMiddleware.cs @@ -0,0 +1,114 @@ +using Fitness_core_BE.Models.IOs; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; + +namespace Fitness_core_BE.Helpers +{ + public class JwtMiddleware + { + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] + public class CustomMiddlewareAttribute : Attribute, IAsyncActionFilter + { + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var config = context.HttpContext.RequestServices.GetService(); + + string token = null; + + var authHeader = context.HttpContext.Request.Headers["MobileAuthToken"].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(authHeader) && authHeader.StartsWith("Bearer ")) + { + token = authHeader.Substring("Bearer ".Length).Trim(); + } + + if (string.IsNullOrWhiteSpace(token)) + { + token = context.HttpContext.Request.Cookies["access_token"]; + } + + if (string.IsNullOrWhiteSpace(token)) + { + context.HttpContext.Response.StatusCode = 401; + await context.HttpContext.Response.WriteAsJsonAsync(new ApiResponse + { + StatusCode = 401, + Message = "Authentication cookie is missing", + Data = null + }); + return; + } + + try + { + var tokenHandler = new JwtSecurityTokenHandler(); + + var xmlKey = config["Jwt:PublicKey"]; + + if (string.IsNullOrWhiteSpace(xmlKey)) + throw new SecurityTokenException("JWT public key not configured."); + + // Use FromXmlString (same approach as the Auth service's RsaKeyProvider) + // to ensure identical key loading behavior + var rsa = RSA.Create(); + rsa.FromXmlString(xmlKey); + var rsaParameters = rsa.ExportParameters(false); + + // Create RsaSecurityKey from RSAParameters directly (not from RSA object) + // to avoid object lifecycle/disposal issues with IdentityModel v8+ + var key = new RsaSecurityKey(rsaParameters) + { + KeyId = ComputeKeyId(rsaParameters) + }; + + var parameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = key, + ValidateIssuer = true, + ValidIssuer = config["Jwt:Issuer"], + ValidateAudience = true, + ValidAudience = config["Jwt:Audience"], + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(2) + }; + + var principal = tokenHandler.ValidateToken(token, parameters, out SecurityToken _); + + context.HttpContext.User = principal; + + context.HttpContext.Items["UserId"] = principal.FindFirst("UserId")?.Value; + context.HttpContext.Items["Email"] = principal.FindFirst("Email")?.Value; + context.HttpContext.Items["UserType"] = principal.FindFirst("UserType")?.Value; + context.HttpContext.Items["RoleId"] = principal.FindFirst("RoleId")?.Value; + + } + catch (Exception) + { + context.HttpContext.Response.StatusCode = 401; + await context.HttpContext.Response.WriteAsJsonAsync(new ApiResponse + { + StatusCode = 401, + Message = "Invalid or expired token in cookie", + Data = null + }); + return; + } + + await next(); + } + + private static string ComputeKeyId(RSAParameters publicParameters) + { + var modulus = publicParameters.Modulus ?? Array.Empty(); + var exponent = publicParameters.Exponent ?? Array.Empty(); + var payload = $"{Convert.ToBase64String(modulus)}.{Convert.ToBase64String(exponent)}"; + var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(payload)); + return Base64UrlEncoder.Encode(hash); + } + } + } +} diff --git a/Helpers/PlatformInvoicePaymentTokenHelper.cs b/Helpers/PlatformInvoicePaymentTokenHelper.cs new file mode 100644 index 0000000..bc1fccb --- /dev/null +++ b/Helpers/PlatformInvoicePaymentTokenHelper.cs @@ -0,0 +1,70 @@ +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace Fitness_core_BE.Helpers +{ + // Mints/validates the short-lived token for the public "pay by link" page used for + // platform (HexaFitness -> gym) invoices. Parallel to InvoicePaymentTokenHelper + // (which is scoped to member-level CustomerInvoice) rather than a shared/generalized + // helper, to avoid touching the token shape already relied on by live member links. + public class PlatformInvoicePaymentTokenHelper + { + private readonly string _signingKey; + private readonly int _expiresInDays; + + public PlatformInvoicePaymentTokenHelper(IConfiguration configuration) + { + _signingKey = configuration["InvoicePaymentToken:SigningKey"] + ?? throw new InvalidOperationException("InvoicePaymentToken:SigningKey is not configured."); + _expiresInDays = int.TryParse(configuration["InvoicePaymentToken:ExpiresInDays"], out var d) ? d : 7; + } + + public string Generate(Guid clientInvoiceId) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_signingKey)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim("ClientInvoiceId", clientInvoiceId.ToString()), + new Claim("Purpose", "PlatformInvoicePayment") + }; + + var expires = DateTime.UtcNow.AddDays(_expiresInDays); + var token = new JwtSecurityToken(claims: claims, expires: expires, signingCredentials: creds); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public Guid? ValidateAndGetInvoiceId(string token) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_signingKey)); + var handler = new JwtSecurityTokenHandler(); + + try + { + var principal = handler.ValidateToken(token, new TokenValidationParameters + { + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKey = key, + ClockSkew = TimeSpan.FromMinutes(2) + }, out _); + + if (principal.FindFirst("Purpose")?.Value != "PlatformInvoicePayment") + return null; + + var idClaim = principal.FindFirst("ClientInvoiceId")?.Value; + return Guid.TryParse(idClaim, out var id) ? id : null; + } + catch + { + return null; + } + } + } +} diff --git a/Helpers/RequestAccessTokenHelper.cs b/Helpers/RequestAccessTokenHelper.cs new file mode 100644 index 0000000..c473574 --- /dev/null +++ b/Helpers/RequestAccessTokenHelper.cs @@ -0,0 +1,71 @@ +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace Fitness_core_BE.Helpers +{ + // Mints/validates the short-lived "requestAccessToken" used between mobile-OTP verification + // and onboarding-request creation. Separate from the RSA-signed AuthHex tokens validated by + // CustomMiddleware, since this repo only holds the AuthHex *public* key (no local RSA signing key). + public class RequestAccessTokenHelper + { + private readonly string _signingKey; + private readonly int _expiresInMinutes; + + public RequestAccessTokenHelper(IConfiguration configuration) + { + _signingKey = configuration["RequestAccessToken:SigningKey"] + ?? throw new InvalidOperationException("RequestAccessToken:SigningKey is not configured."); + _expiresInMinutes = int.TryParse(configuration["RequestAccessToken:ExpiresInMinutes"], out var m) ? m : 15; + } + + public (string token, int expiresIn) Generate(string mobileNumber) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_signingKey)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim("MobileNumber", mobileNumber), + new Claim("Purpose", "RequestAccess") + }; + + var expires = DateTime.UtcNow.AddMinutes(_expiresInMinutes); + var token = new JwtSecurityToken( + claims: claims, + expires: expires, + signingCredentials: creds); + + return (new JwtSecurityTokenHandler().WriteToken(token), _expiresInMinutes * 60); + } + + public string? ValidateAndGetMobileNumber(string token) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_signingKey)); + var handler = new JwtSecurityTokenHandler(); + + try + { + var principal = handler.ValidateToken(token, new TokenValidationParameters + { + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKey = key, + ClockSkew = TimeSpan.FromMinutes(2) + }, out _); + + if (principal.FindFirst("Purpose")?.Value != "RequestAccess") + return null; + + return principal.FindFirst("MobileNumber")?.Value; + } + catch + { + return null; + } + } + } +} diff --git a/Helpers/RequireWorkerKeyAttribute.cs b/Helpers/RequireWorkerKeyAttribute.cs new file mode 100644 index 0000000..123f2d0 --- /dev/null +++ b/Helpers/RequireWorkerKeyAttribute.cs @@ -0,0 +1,55 @@ +using System.Security.Cryptography; +using System.Text; +using Fitness_core_BE.Models.IOs; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Fitness_core_BE.Helpers +{ + // Gates the subscription-billing worker endpoint behind a single shared secret in the + // X-Worker-Key header. Distinct from JwtMiddleware/CustomMiddleware (human/agent session + // auth) and from PaymentService:InternalApiKey (Core's own outbound key to Payment-Service) — + // this authenticates the trusted scheduler calling back into Core. Writes the 401/503 + // response directly (like RequiresPermissionAttribute) rather than throwing, because Core + // has no global exception handler to turn a thrown exception into the right status code on + // this unauthenticated route. + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public sealed class RequireWorkerKeyAttribute : Attribute, IAsyncActionFilter + { + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var configuration = context.HttpContext.RequestServices.GetService(typeof(IConfiguration)) as IConfiguration; + var configuredKey = configuration?["Worker:InternalApiKey"]; + + if (string.IsNullOrWhiteSpace(configuredKey)) + { + await WriteErrorAsync(context, 503, "Worker:InternalApiKey is not configured."); + return; + } + + var providedKey = context.HttpContext.Request.Headers["X-Worker-Key"].FirstOrDefault(); + if (string.IsNullOrWhiteSpace(providedKey) || !FixedTimeEquals(providedKey, configuredKey)) + { + await WriteErrorAsync(context, 401, "Invalid worker key."); + return; + } + + await next(); + } + + private static async Task WriteErrorAsync(ActionExecutingContext context, int statusCode, string message) + { + context.HttpContext.Response.StatusCode = statusCode; + await context.HttpContext.Response.WriteAsJsonAsync(new ApiResponse + { + StatusCode = statusCode, + Message = message, + Data = null + }); + } + + private static bool FixedTimeEquals(string a, string b) + { + return CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(a), Encoding.UTF8.GetBytes(b)); + } + } +} diff --git a/Helpers/RequiresPermissionAttribute.cs b/Helpers/RequiresPermissionAttribute.cs new file mode 100644 index 0000000..257aa59 --- /dev/null +++ b/Helpers/RequiresPermissionAttribute.cs @@ -0,0 +1,43 @@ +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models.IOs; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Fitness_core_BE.Helpers +{ + // Gates an action/controller behind one nav-section permission key. Must run AFTER + // CustomMiddlewareAttribute (which populates HttpContext.Items["UserId"]) — guaranteed by + // Order below, since CustomMiddlewareAttribute has no IOrderedFilter and runs at the + // ASP.NET Core default Order (0), regardless of attribute declaration order on the method. + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] + public class RequiresPermissionAttribute : Attribute, IAsyncActionFilter, IOrderedFilter + { + private readonly string _navKey; + + public int Order => 10; + + public RequiresPermissionAttribute(string navKey) + { + _navKey = navKey; + } + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var permissionService = context.HttpContext.RequestServices.GetService(typeof(IPermissionService)) as IPermissionService; + var ctx = await permissionService!.GetPermissionContextAsync(context.HttpContext); + + if (!ctx.IsOwner && !ctx.AllowedNavKeys.Contains(_navKey)) + { + context.HttpContext.Response.StatusCode = 403; + await context.HttpContext.Response.WriteAsJsonAsync(new ApiResponse + { + StatusCode = 403, + Message = "You don't have permission to access this feature.", + Data = null + }); + return; + } + + await next(); + } + } +} diff --git a/Helpers/SandboxTokenHelper.cs b/Helpers/SandboxTokenHelper.cs new file mode 100644 index 0000000..2ed22c6 --- /dev/null +++ b/Helpers/SandboxTokenHelper.cs @@ -0,0 +1,50 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Fitness_core_BE.Helpers +{ + // Short, shareable /sandbox/{token} code — replaces the old raw Guid. Made of a + // time-derived prefix (minutes since a fixed epoch, base62) so codes drift forward + // over time, plus a random suffix for uniqueness among requests created in the same + // minute. Not a security boundary: the sandbox is a temporary trial state, not a + // login credential. + public static class SandboxTokenHelper + { + private const string Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + private const int TimePartLength = 5; + private const int RandomPartLength = 5; + private static readonly DateTime Epoch = new(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + public static string Generate(DateTime createdAtUtc) + { + long minutesSinceEpoch = Math.Max(0, (long)(createdAtUtc - Epoch).TotalMinutes); + string timePart = ToBaseAlphabet(minutesSinceEpoch).PadLeft(TimePartLength, '0'); + if (timePart.Length > TimePartLength) + timePart = timePart[^TimePartLength..]; + + return timePart + RandomSuffix(RandomPartLength); + } + + private static string RandomSuffix(int length) + { + var bytes = new byte[length]; + RandomNumberGenerator.Fill(bytes); + var sb = new StringBuilder(length); + foreach (var b in bytes) + sb.Append(Alphabet[b % Alphabet.Length]); + return sb.ToString(); + } + + private static string ToBaseAlphabet(long value) + { + if (value <= 0) return "0"; + var sb = new StringBuilder(); + while (value > 0) + { + sb.Insert(0, Alphabet[(int)(value % Alphabet.Length)]); + value /= Alphabet.Length; + } + return sb.ToString(); + } + } +} diff --git a/Helpers/TenantResolutionAttribute.cs b/Helpers/TenantResolutionAttribute.cs new file mode 100644 index 0000000..73c4a47 --- /dev/null +++ b/Helpers/TenantResolutionAttribute.cs @@ -0,0 +1,54 @@ +using Fitness_core_BE.Infra.Tenancy; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Fitness_core_BE.Helpers +{ + // Resolves the request's TenantId into the scoped ITenantContext, so downstream + // repos/services can read it instead of re-deriving ClientId ad hoc. Must run AFTER + // CustomMiddlewareAttribute (order 0, populates HttpContext.Items["UserId"]) and BEFORE + // RequiresPermissionAttribute (order 10) — Order = 5 guarantees this regardless of + // attribute declaration order on the method, same technique RequiresPermissionAttribute + // itself uses. + // + // Single-club deployment: everyone (Client owner, Staff, Customer) resolves to the same + // one Client row. GYM's split served many gyms, so a Customer's active gym was picked + // per-request from a caller-supplied X-Tenant-Id header, independently verified against + // the DB. There's only one gym here, so that header/verification step is gone — a + // Customer always resolves to the one club, the same way Client/Staff already did. + // Admin: platform-wide, no tenant. + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] + public class TenantResolutionAttribute : Attribute, IAsyncActionFilter, IOrderedFilter + { + public int Order => 5; + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var httpContext = context.HttpContext; + var userRoleHelper = httpContext.RequestServices.GetRequiredService(); + var tenantContext = httpContext.RequestServices.GetRequiredService(); + + var userId = DataExtractor.GetUserIdFromContext(httpContext); + var userType = await userRoleHelper.GetUserType(userId); + + if (userType == "Admin") + { + tenantContext.SetTenant(null, isPlatformAdmin: true); + await next(); + return; + } + + if (userType == "Client" || userType == "Staff") + { + var clientId = await userRoleHelper.GetClientId(userId); + tenantContext.SetTenant(clientId, isPlatformAdmin: false); + await next(); + return; + } + + // Customer -- resolves to the one club, no header needed. + var defaultClientId = await userRoleHelper.GetDefaultClientId(); + tenantContext.SetTenant(defaultClientId, isPlatformAdmin: false); + await next(); + } + } +} diff --git a/Helpers/UserRoleHelper.cs b/Helpers/UserRoleHelper.cs new file mode 100644 index 0000000..a7418e1 --- /dev/null +++ b/Helpers/UserRoleHelper.cs @@ -0,0 +1,149 @@ +using Fitness_core_BE.Exceptions; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Helpers +{ + public class UserRoleHelper + { + // Admins/Customer/Client are central-bucket; Staff and CustomerSubscriptions are + // tenant-bucket. + private readonly CentralDbContext _central; + private readonly TenantDbContext _context; + + public UserRoleHelper(CentralDbContext central, TenantDbContext context) + { + _central = central; + _context = context; + } + + public async Task GetAdminId(Guid userId) + { + var admin = await _central.Admins + .AsNoTracking() + .Where(a => a.FKUserId == userId && a.IsActive) + .Select(a => a.AdminId) + .FirstOrDefaultAsync(); + + return admin == Guid.Empty ? null : admin; + } + public async Task GetCusId(Guid userId) + { + var cus = await _central.Customer + .AsNoTracking() + .Where(a => a.FKUserId == userId && a.IsActive) + .Select(a => a.CusId) + .FirstOrDefaultAsync(); + + if (cus == Guid.Empty) + throw new NotFoundException("Customer not found or inactive"); + + return cus; + } + + public async Task GetStaffId(Guid userId) + { + var staff = await _context.Staff + .AsNoTracking() + .Where(s => s.FKUserId == userId && s.IsActive) + .Select(s => s.StaffId) + .FirstOrDefaultAsync(); + + return staff == Guid.Empty ? null : staff; + } + + public async Task GetClientId(Guid userId) + { + var clientId = await _central.Client + .AsNoTracking() + .Where(c => c.FKUserId == userId) + .Select(c => c.ClientId) + .FirstOrDefaultAsync(); + + // Staff can use the same tenant-scoped dashboard and real-time hub. + if (clientId == Guid.Empty) + { + clientId = await _context.Staff + .AsNoTracking() + .Where(s => s.FKUserId == userId && s.IsActive) + .Select(s => s.FKClientId) + .FirstOrDefaultAsync(); + } + + if (clientId == Guid.Empty) + throw new NotFoundException("Client not found"); + + return clientId; + } + + // Strict owner lookup used by RBAC decisions. GetClientId intentionally falls + // back to a staff member's tenant so normal tenant-scoped data queries work; + // that fallback must never make staff users appear to be gym owners. + public async Task GetOwnedClientId(Guid userId) + { + var clientId = await _central.Client + .AsNoTracking() + .Where(c => c.FKUserId == userId) + .Select(c => c.ClientId) + .FirstOrDefaultAsync(); + + return clientId == Guid.Empty ? null : clientId; + } + + // Single-club deployment: exactly one Client row exists, so any caller that just needs + // "the club" (rather than a specific user's club) can use this instead of a per-user + // lookup. Used by TenantResolutionAttribute's Customer branch, which used to read a + // caller-supplied X-Tenant-Id header to pick from many gyms -- there's only one now. + public async Task GetDefaultClientId() + { + var clientId = await _central.Client + .AsNoTracking() + .Select(c => c.ClientId) + .FirstOrDefaultAsync(); + + if (clientId == Guid.Empty) + throw new NotFoundException("Club is not configured yet"); + + return clientId; + } + + public async Task GetClientIdByCusId(Guid cusId) + { + var clientId = await _context.CustomerSubscriptions + .AsNoTracking() + .Where(s => s.FKCustomerId == cusId && s.IsActive) + .Select(s => s.CustomerPlan.FKClientId) + .FirstOrDefaultAsync(); + + if (clientId == Guid.Empty) + throw new NotFoundException("No active subscription found for customer"); + + return clientId; + } + + public async Task GetUserType(Guid userId) + { + var isAdmin = await _central.Admins + .AsNoTracking() + .AnyAsync(a => a.FKUserId == userId && a.IsActive); + if (isAdmin) + return "Admin"; + var isCustomer = await _central.Customer + .AsNoTracking() + .AnyAsync(c => c.FKUserId == userId && c.IsActive); + if (isCustomer) + return "Customer"; + var isClient = await _central.Client + .AsNoTracking() + .AnyAsync(cl => cl.FKUserId == userId); + if (isClient) + return "Client"; + + var isStaff = await _context.Staff + .AsNoTracking() + .AnyAsync(s => s.FKUserId == userId && s.IsActive); + if (isStaff) + return "Staff"; + throw new NotFoundException("User role not found or inactive"); + } + } +} diff --git a/Hubs/AccessHub.cs b/Hubs/AccessHub.cs new file mode 100644 index 0000000..a861765 --- /dev/null +++ b/Hubs/AccessHub.cs @@ -0,0 +1,36 @@ +using Fitness_core_BE.Auth; +using Fitness_core_BE.Helpers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +namespace Fitness_core_BE.Hubs +{ + /// + /// Tenant-isolated browser channel for device inventory, attendance, and access + /// state notifications. The client id is always resolved from the authenticated + /// user and is never accepted from the browser. + /// + [Authorize(AuthenticationSchemes = UserJwtDefaults.AuthenticationScheme)] + public sealed class AccessHub : Hub + { + private readonly UserRoleHelper _userRoleHelper; + + public AccessHub(UserRoleHelper userRoleHelper) + { + _userRoleHelper = userRoleHelper; + } + + public static string ClientGroup(Guid clientId) => $"access-client-{clientId:N}"; + + public override async Task OnConnectedAsync() + { + var userIdValue = Context.User?.FindFirst("UserId")?.Value; + if (!Guid.TryParse(userIdValue, out var userId)) + throw new HubException("Authenticated user id is missing."); + + var clientId = await _userRoleHelper.GetClientId(userId); + await Groups.AddToGroupAsync(Context.ConnectionId, ClientGroup(clientId)); + await base.OnConnectedAsync(); + } + } +} diff --git a/Hubs/ChatHub.cs b/Hubs/ChatHub.cs new file mode 100644 index 0000000..889d081 --- /dev/null +++ b/Hubs/ChatHub.cs @@ -0,0 +1,191 @@ +using Fitness_core_BE.AI; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using Microsoft.AspNetCore.SignalR; +using System.Net.Http.Headers; +using System.Numerics; +using System.Text.Json; +using static System.Runtime.InteropServices.JavaScript.JSType; + +namespace Fitness_core_BE.Hubs +{ + public class ChatHub : Hub + { + private readonly IChatService _chatService; + public readonly MealPlanOrchestrator _mealPlanOrchestrator; + public readonly WorkoutPlanOrchestrator _workoutPlanOrchestrator; + + public ChatHub(IChatService chatService, MealPlanOrchestrator mealPlanOrchestrator, WorkoutPlanOrchestrator workoutPlanOrchestrator) + { + _chatService = chatService; + _mealPlanOrchestrator = mealPlanOrchestrator; + _workoutPlanOrchestrator = workoutPlanOrchestrator; + } + + private Guid ResolveUserId() + { + // Later: Context.User.Claims (JWT) + return Guid.Parse("d12fb5a6-969c-4fb2-a7f8-a5bc54ed0f11"); + } + + public async Task JoinChat(object payload) + { + var data = DeserializePayload.Deserialize(payload); + Guid chatId = DataExtractor.GetRequiredGuid(data, "chatId"); + + //Guid userId = ResolveUserId(); + Guid userId = DataExtractor.GetRequiredGuid(data, "userId"); + + if (!await _chatService.CanUserAccessChatAsync(chatId, userId)) + throw new HubException("Unauthorized to join this chat"); + + await Groups.AddToGroupAsync( + Context.ConnectionId, + $"chat-{chatId}" + ); + + var history = await _chatService.GetMessagesAsync(new + { + chatId = chatId, + userId = userId, + skip = 0, + take = 30 + }); + + await Clients.Caller.SendAsync("ReceiveHistory", history); + } + + public async Task SendMessage(object payload) + { + var data = DeserializePayload.Deserialize(payload); + string reason = DataExtractor.GetRequiredString(data, "reason"); + Guid chatId = DataExtractor.GetRequiredGuid(data, "chatId"); + Guid userId = DataExtractor.GetRequiredGuid(data, "userId"); + string message = DataExtractor.GetRequiredString(data, "message"); + + switch (reason) + { + case "user": + var savedMessage = await _chatService.SendMessageAsync(new + { + chatId = chatId, + senderId = userId, + message = message, + type = "userMessages" + }); + + await Clients.Group($"chat-{chatId}") + .SendAsync("ReceiveMessage", savedMessage); + break; + + case "mealPlan": + await SendChatMessageAsync( + chatId, + userId, + message, + "userMealReq" + ); + string messageType = DataExtractor.GetRequiredString(data, "messageType"); + if (messageType == "generateMealPlan") + { + var plan = await _mealPlanOrchestrator + .GeneratePlanAsync(payload); + + string aiMessage = JsonSerializer.Serialize(plan); + + await SendChatMessageAsync( + chatId, + AIAgents.MealAgentId, + aiMessage, + "createdPlan" + ); + } + + if (messageType == "modifyMealPlan") + { + var updated = await _mealPlanOrchestrator + .UpdatePlanAsync(payload); + + string aiMessage = JsonSerializer.Serialize(updated); + + await SendChatMessageAsync( + chatId, + AIAgents.MealAgentId, + aiMessage, + "updatedPlan" + ); + } + break; + + case "workoutPlan": + await SendChatMessageAsync( + chatId, + userId, + message, + "userWorkoutReq" + ); + string workoutMessageType = DataExtractor.GetRequiredString(data, "messageType"); + if (workoutMessageType == "generateWorkoutPlan") + { + var plan = await _workoutPlanOrchestrator + .GeneratePlanAsync(payload); + + string aiMessage = JsonSerializer.Serialize(plan); + + await SendChatMessageAsync( + chatId, + AIAgents.WorkoutAgentId, + aiMessage, + "createdPlan" + ); + } + + if (workoutMessageType == "modifyWorkoutPlan") + { + var updated = await _workoutPlanOrchestrator + .UpdatePlanAsync(payload); + + string aiMessage = JsonSerializer.Serialize(updated); + + await SendChatMessageAsync( + chatId, + AIAgents.WorkoutAgentId, + aiMessage, + "updatedPlan" + ); + } + break; + + default: + throw new HubException("Invalid reason for sending message."); + } + + } + + public async Task LeaveChat(object payload) + { + var data = DeserializePayload.Deserialize(payload); + Guid chatId = DataExtractor.GetRequiredGuid(data, "chatId"); + + await Groups.RemoveFromGroupAsync( + Context.ConnectionId, + $"chat-{chatId}" + ); + } + + private async Task SendChatMessageAsync( Guid chatId, Guid senderId, string message,string type) + { + var savedMessage = await _chatService.SendMessageAsync(new + { + chatId = chatId, + senderId = senderId, + message = message, + type = type + }); + + await Clients.Group($"chat-{chatId}") + .SendAsync("ReceiveMessage", savedMessage); + } + } +} diff --git a/Infra/Tenancy/ITenantContext.cs b/Infra/Tenancy/ITenantContext.cs new file mode 100644 index 0000000..46266d3 --- /dev/null +++ b/Infra/Tenancy/ITenantContext.cs @@ -0,0 +1,30 @@ +namespace Fitness_core_BE.Infra.Tenancy +{ + // Request-scoped tenant info, populated once per request by TenantResolutionAttribute + // (Helpers/TenantResolutionAttribute.cs) so downstream repos/services can read the + // caller's resolved TenantId instead of re-deriving ClientId ad hoc via UserRoleHelper. + public interface ITenantContext + { + // Client owner / Staff: their one gym. Customer: the gym validated from X-Tenant-Id + // for this request, if any was sent. Admin: always null (see IsPlatformAdmin). + Guid? TenantId { get; } + bool IsPlatformAdmin { get; } + bool IsResolved { get; } + + void SetTenant(Guid? tenantId, bool isPlatformAdmin); + } + + public class TenantContext : ITenantContext + { + public Guid? TenantId { get; private set; } + public bool IsPlatformAdmin { get; private set; } + public bool IsResolved { get; private set; } + + public void SetTenant(Guid? tenantId, bool isPlatformAdmin) + { + TenantId = tenantId; + IsPlatformAdmin = isPlatformAdmin; + IsResolved = true; + } + } +} diff --git a/Infra/Tenancy/TenantSchemaIterator.cs b/Infra/Tenancy/TenantSchemaIterator.cs new file mode 100644 index 0000000..f1ca714 --- /dev/null +++ b/Infra/Tenancy/TenantSchemaIterator.cs @@ -0,0 +1,99 @@ +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Infra.Tenancy +{ + // A matched Central/Tenant context pair bound to one specific, already-known schema -- + // built for callers that have no HttpContext to resolve a tenant from (background jobs), + // so TenantConnectionResolver's request-based resolution can't apply. Dispose both + // contexts together via `await using`. + public sealed class TenantSchemaContext : IAsyncDisposable + { + public Guid ClientId { get; } + public string SchemaName { get; } + public CentralDbContext Central { get; } + public TenantDbContext Tenant { get; } + + public TenantSchemaContext(Guid clientId, string schemaName, CentralDbContext central, TenantDbContext tenant) + { + ClientId = clientId; + SchemaName = schemaName; + Central = central; + Tenant = tenant; + } + + public async ValueTask DisposeAsync() + { + await Tenant.DisposeAsync(); + await Central.DisposeAsync(); + } + } + + // Single club, single database -- every background job that used to loop over "every + // provisioned tenant schema" now just runs once against the one Client row / one DB. Kept + // as a class (rather than deleting it and rewriting every caller) so OverdueLockJob, + // SubscriptionRenewalJob and AdminBackgroundJobsController keep working unchanged. + public class TenantSchemaIterator + { + private readonly IConfiguration _configuration; + + public TenantSchemaIterator(IConfiguration configuration) + { + _configuration = configuration; + } + + // Every client row (there's exactly one for Vijaya) -- callers open their own + // TenantSchemaContext per entry. + public async IAsyncEnumerable<(Guid ClientId, string SchemaName)> GetProvisionedRegistrationsAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) + { + List clientIds; + await using (var central = CreateCentralDbContext()) + { + clientIds = await central.Client.AsNoTracking().Select(c => c.ClientId).ToListAsync(ct); + } + + foreach (var clientId in clientIds) + yield return (clientId, "default"); + } + + // One client's schema, when the caller already knows the ClientId (e.g. OverdueLockJob's + // existing per-client loop from the central Client table). Null if the client doesn't exist. + public async Task OpenSchemaForClientAsync(Guid clientId, CancellationToken ct = default) + { + bool exists; + await using (var central = CreateCentralDbContext()) + { + exists = await central.Client.AsNoTracking().AnyAsync(c => c.ClientId == clientId, ct); + } + + return exists ? OpenSchema(clientId, "default") : null; + } + + // Opens a matched context pair for an already-known ClientId. Building the contexts + // doesn't itself open a connection -- EF Core connects lazily on first query. + public TenantSchemaContext OpenSchema(Guid clientId, string schemaName) + { + var central = CreateCentralDbContext(); + var tenant = CreateTenantDbContext(); + return new TenantSchemaContext(clientId, schemaName, central, tenant); + } + + private CentralDbContext CreateCentralDbContext() + { + var connectionString = _configuration.GetConnectionString("DefaultConnection") + ?? throw new InvalidOperationException("DefaultConnection is not configured."); + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseMySql(connectionString, new MySqlServerVersion(new Version(8, 0, 0))); + return new CentralDbContext(optionsBuilder.Options); + } + + private TenantDbContext CreateTenantDbContext() + { + var connectionString = _configuration.GetConnectionString("DefaultConnection") + ?? throw new InvalidOperationException("DefaultConnection is not configured."); + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseMySql(connectionString, new MySqlServerVersion(new Version(8, 0, 0))); + return new TenantDbContext(optionsBuilder.Options); + } + } +} diff --git a/Infra/UoW/EFUnitOfWork.cs b/Infra/UoW/EFUnitOfWork.cs new file mode 100644 index 0000000..5861dab --- /dev/null +++ b/Infra/UoW/EFUnitOfWork.cs @@ -0,0 +1,75 @@ +using Microsoft.EntityFrameworkCore.Storage; + +namespace Fitness_core_BE.Infra.UoW +{ + public class EFUnitOfWork : IUnitOfWork + { + // Central and tenant are separate physical connections -- each gets its own local + // transaction. There is no distributed-transaction coordinator wired up, so if one + // side's commit fails after the other has already committed, that is NOT rolled back + // atomically; true cross-DB atomicity is deferred to a saga/compensating-action + // pattern (see the migration plan's Phase 2 step 8). Both sides are always + // begun/committed/rolled-back together so ordinary success/failure flows (the vast + // majority of call sites) still behave exactly as the single-context version did. + private readonly CentralDbContext _central; + private readonly TenantDbContext _tenant; + private IDbContextTransaction? _centralTransaction; + private IDbContextTransaction? _tenantTransaction; + + public EFUnitOfWork(CentralDbContext central, TenantDbContext tenant) + { + _central = central; + _tenant = tenant; + } + + public async Task BeginAsync(CancellationToken ct = default) + { + if (_centralTransaction == null) + _centralTransaction = await _central.Database.BeginTransactionAsync(ct); + if (_tenantTransaction == null) + _tenantTransaction = await _tenant.Database.BeginTransactionAsync(ct); + } + + public async Task SaveChangesAsync(CancellationToken ct = default) + { + await _central.SaveChangesAsync(ct); + await _tenant.SaveChangesAsync(ct); + } + + public async Task CommitAsync(CancellationToken ct = default) + { + await _central.SaveChangesAsync(ct); + await _tenant.SaveChangesAsync(ct); + + if (_centralTransaction != null) + { + await _centralTransaction.CommitAsync(ct); + await _centralTransaction.DisposeAsync(); + _centralTransaction = null; + } + if (_tenantTransaction != null) + { + await _tenantTransaction.CommitAsync(ct); + await _tenantTransaction.DisposeAsync(); + _tenantTransaction = null; + } + } + + public async Task RollbackAsync(CancellationToken ct = default) + { + if (_centralTransaction != null) + { + await _centralTransaction.RollbackAsync(ct); + await _centralTransaction.DisposeAsync(); + _centralTransaction = null; + } + if (_tenantTransaction != null) + { + await _tenantTransaction.RollbackAsync(ct); + await _tenantTransaction.DisposeAsync(); + _tenantTransaction = null; + } + } + + } +} diff --git a/Infra/UoW/IUnitOfWork.cs b/Infra/UoW/IUnitOfWork.cs new file mode 100644 index 0000000..ddde8d6 --- /dev/null +++ b/Infra/UoW/IUnitOfWork.cs @@ -0,0 +1,10 @@ +namespace Fitness_core_BE.Infra.UoW +{ + public interface IUnitOfWork + { + Task BeginAsync(CancellationToken ct = default); + Task CommitAsync(CancellationToken ct = default); + Task RollbackAsync(CancellationToken ct = default); + Task SaveChangesAsync(CancellationToken ct = default); + } +} diff --git a/Interfaces/IAccessDeviceRepo.cs b/Interfaces/IAccessDeviceRepo.cs new file mode 100644 index 0000000..2197996 --- /dev/null +++ b/Interfaces/IAccessDeviceRepo.cs @@ -0,0 +1,103 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface IAccessDeviceRepo + { + Task AddDeviceAsync(AccessDevice device); + Task RemoveDeviceAsync(Guid clientId); + Task GetDeviceByDId(Guid deviceId); + Task GetDeviceByIdAsync(Guid deviceId, CancellationToken ct = default); + Task GetDeviceByHardwareIdAsync( + Guid clientId, + string hardwareId, + CancellationToken ct = default); + Task GetActiveDeviceForClientAsync( + Guid clientId, + Guid deviceId, + CancellationToken ct = default); + Task> GetAgentDevicesAsync( + Guid clientId, + Guid agentId, + IReadOnlyCollection deviceIds, + CancellationToken ct = default); + Task GetDeviceByCId(Guid clientId); + Task AddCustomerAccessAsync(CustomerAccess customerAccess); + Task GetByIdAsync(int deviceUserId); + Task GetByDeviceAndUserIdAsync(Guid deviceId, int deviceUserId); + Task GetByCusGuidAsync(Guid CusId); + Task UpdateAsync(CustomerAccess customerAccess); + Task> GetDevicesByClientAsync(Guid clientId); + Task DeviceBelongsToClientAsync(Guid deviceId, Guid clientId); + Task GetCustomerByIdAsync(Guid customerId, CancellationToken ct = default); + Task GetStaffByIdAsync(Guid clientId, Guid staffId, CancellationToken ct = default); + /// + /// The other active customers sharing this customer's active group/couple + /// subscriptions at this gym (excludes the customer themselves). Empty for an + /// Individual plan, so callers can cascade unconditionally. + /// + Task> GetCoMembersOnSharedSubscriptionsAsync( + Guid clientId, + Guid customerId, + CancellationToken ct = default); + Task IsActiveCustomerMemberAsync( + Guid clientId, + Guid customerId, + CancellationToken ct = default); + Task GetLatestActiveClientIdForCustomerAsync( + Guid customerId, + CancellationToken ct = default); + Task GetAccessProfileAsync( + Guid clientId, + Guid userId, + CancellationToken ct = default); + Task GetAccessStatusProfileAsync( + Guid clientId, + Guid userId, + CancellationToken ct = default); + Task> GetAccessProfilesByClientAsync( + Guid clientId, + CancellationToken ct = default); + Task AddAccessProfileAsync(AccessProfile profile, CancellationToken ct = default); + Task GetBindingAsync( + long accessProfileId, + Guid deviceId, + CancellationToken ct = default); + Task GetBindingByEmployeeNoAsync( + Guid deviceId, + string employeeNo, + CancellationToken ct = default); + Task> GetBindingsForDeviceAsync( + Guid deviceId, + CancellationToken ct = default); + Task AddBindingAsync(AccessDeviceBinding binding, CancellationToken ct = default); + Task IsDeviceEmployeeNoOccupiedAsync( + Guid deviceId, + string employeeNo, + Guid exceptUserId, + CancellationToken ct = default); + Task> GetDuplicateUnlinkedEnrollmentsAsync( + Guid deviceId, + string employeeNo, + int deviceUserId, + CancellationToken ct = default); + Task GetUnlinkedEnrollmentByEmployeeNoAsync( + Guid deviceId, + string employeeNo, + CancellationToken ct = default); + Task AddUnlinkedEnrollmentAsync( + UnlinkedDeviceEnrollment enrollment, + CancellationToken ct = default); + Task GetCustomerIdForUserAtClientAsync( + Guid userId, + Guid clientId, + CancellationToken ct = default); + Task IsDeviceCommandableAsync(Guid deviceId, CancellationToken ct = default); + + // Fingerprint-enrolled-directly-on-device reconciliation (req 3). + Task UpsertUnlinkedEnrollmentAsync(Guid deviceId, int deviceUserId, string? reportedName); + Task> GetUnlinkedEnrollmentsByClientAsync(Guid clientId); + Task GetUnlinkedEnrollmentByIdAsync(Guid id); + Task CustomerAccessIdExistsAsync(int id); + } +} diff --git a/Interfaces/IActivityLogRepository.cs b/Interfaces/IActivityLogRepository.cs new file mode 100644 index 0000000..11abc4d --- /dev/null +++ b/Interfaces/IActivityLogRepository.cs @@ -0,0 +1,15 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface IActivityLogRepository + { + Task AddActivityLogAsync(ActivityLog log, CancellationToken ct = default); + Task> GetActivityLogsAsync( + string searchTerm = null, + DateTime? startDate = null, + DateTime? endDate = null, + CancellationToken ct = default); + Task GetUserNameAsync(Guid userId, CancellationToken ct = default); + } +} diff --git a/Interfaces/IAdminClientDirectoryRepository.cs b/Interfaces/IAdminClientDirectoryRepository.cs new file mode 100644 index 0000000..bf4dbfa --- /dev/null +++ b/Interfaces/IAdminClientDirectoryRepository.cs @@ -0,0 +1,11 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface IAdminClientDirectoryRepository + { + Task<(List Items, int Total)> ListAsync(string? search, int page, int pageSize, CancellationToken ct = default); + Task GetByIdAsync(Guid clientId, CancellationToken ct = default); + Task ClientExistsAsync(Guid clientId, CancellationToken ct = default); + } +} diff --git a/Interfaces/IAdminSuggestionRepository.cs b/Interfaces/IAdminSuggestionRepository.cs new file mode 100644 index 0000000..2802013 --- /dev/null +++ b/Interfaces/IAdminSuggestionRepository.cs @@ -0,0 +1,15 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface IAdminSuggestionRepository + { + Task<(List Workouts, List Meals)> GetPublishedAsync(CancellationToken ct = default); + Task FindWorkoutCategoryAsync(string name, CancellationToken ct = default); + Task FindMealCategoryAsync(string name, CancellationToken ct = default); + Task AddWorkoutCategoryAsync(WorkoutCategory category, CancellationToken ct = default); + Task AddMealCategoryAsync(MealCategory category, CancellationToken ct = default); + Task AddWorkoutAsync(WorkoutPlan plan, CancellationToken ct = default); + Task AddMealAsync(MealPlan plan, CancellationToken ct = default); + } +} diff --git a/Interfaces/IAgentRepo.cs b/Interfaces/IAgentRepo.cs new file mode 100644 index 0000000..560fcdf --- /dev/null +++ b/Interfaces/IAgentRepo.cs @@ -0,0 +1,13 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface IAgentRepo + { + Task AddAsync(Agent agent); + Task GetByApiKeyHashAsync(string apiKeyHash); + Task GetByIdAsync(Guid agentId); + Task> GetByClientIdAsync(Guid clientId); + Task UpdateLastSeenAsync(Guid agentId, DateTime seenAt); + } +} diff --git a/Interfaces/IAttendanceRepository.cs b/Interfaces/IAttendanceRepository.cs new file mode 100644 index 0000000..8819695 --- /dev/null +++ b/Interfaces/IAttendanceRepository.cs @@ -0,0 +1,18 @@ +using Fitness_core_BE.DTOs.Attendance; +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface IAttendanceRepository + { + Task GetLatestAttendanceAsync(Guid clientId); + Task> GetAttendanceKeysAsync(Guid clientId, DateTime fromTime); + Task> GetSourceEventKeysAsync( + IReadOnlyCollection candidateKeys, + CancellationToken ct = default); + Task AddAttendancesAsync(IEnumerable attendances); + Task> GetLatestPerPersonAsync(Guid clientId); + Task> GetAttendanceCountsAsync(Guid clientId, DateTime fromDate, DateTime toDate); + Task> GetTodayAttendanceAsync(Guid clientId); + } +} diff --git a/Interfaces/IBookingRepository.cs b/Interfaces/IBookingRepository.cs new file mode 100644 index 0000000..805d8f2 --- /dev/null +++ b/Interfaces/IBookingRepository.cs @@ -0,0 +1,17 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface IBookingRepository + { + Task CreateBooking(Booking booking); + Task GetBookingById(Guid bookingId); + + // "Confirmed" only -- an OTP that's already been used/cancelled shouldn't check anyone in. + Task GetActiveBookingByOtp(string otpCode); + + Task> GetBookingsForSession(Guid facilitySessionId); + Task> GetBookingsForCustomer(Guid customerId); + Task CustomerHasActiveBookingForSession(Guid facilitySessionId, Guid customerId); + } +} diff --git a/Interfaces/IChatRepo.cs b/Interfaces/IChatRepo.cs new file mode 100644 index 0000000..967f53b --- /dev/null +++ b/Interfaces/IChatRepo.cs @@ -0,0 +1,14 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface IChatRepo + { + Task GetChatByIdAsync(Guid chatId); + Task GetChatBetweenUsersAsync(Guid userAId, Guid userBId); + Task CreateChatAsync(Chat chat); + Task AddMessageAsync(ChatMessages message); + Task> GetMessagesAsync(Guid chatId, int skip, int take); + Task IsUserInChatAsync(Guid chatId, Guid userId); + } +} diff --git a/Interfaces/IClientInvoiceRepository.cs b/Interfaces/IClientInvoiceRepository.cs new file mode 100644 index 0000000..7356b1d --- /dev/null +++ b/Interfaces/IClientInvoiceRepository.cs @@ -0,0 +1,13 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface IClientInvoiceRepository + { + Task CreateClientInvoiceFromSubscription(Guid subscriptionId); + Task GetClientInvoiceById(Guid clientInvoiceId); + Task> GetClientInvoicesByClientId(Guid clientId); + Task> GetClientInvoicesByUserId(Guid userId); + Task UpdateClientInvoiceStatus(Guid clientInvoiceId, string status); + } +} diff --git a/Interfaces/IClientRepository.cs b/Interfaces/IClientRepository.cs new file mode 100644 index 0000000..3d136b3 --- /dev/null +++ b/Interfaces/IClientRepository.cs @@ -0,0 +1,70 @@ +using Fitness_core_BE.Domain; +using Fitness_core_BE.DTOs.Customer; +using Fitness_core_BE.DTOs.CustomerPlan; +using Fitness_core_BE.DTOs.Meal; +using Fitness_core_BE.DTOs.Workout; +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface IClientRepository +{ + + Task> GetAllCustomers(); + Task GetCustomerById(Guid customerId); + + Task> GetAllClientsAsync(CancellationToken ct = default); + Task GetClientByIdAsync(Guid clientId, CancellationToken ct = default); + Task> GetClientListAsync(Guid clientId, CancellationToken ct = default); + Task RegisterClientAsync(Client client, CancellationToken ct = default); + Task RegisterBussinessDetailAsync(ClientBusiness clientBusiness, CancellationToken ct = default); + // Tracked (unlike GetClientByIdAsync) so the caller can mutate & persist via UoW. + Task GetPrimaryBusinessByClientIdAsync(Guid clientId, CancellationToken ct = default); + Task GetClientEntityByIdAsync(Guid clientId, CancellationToken ct = default); + Task> GetFollowedWorkoutPlansAsync(Guid customerId, CancellationToken ct = default); + Task> GetFollowedMealPlansAsync(Guid customerId, CancellationToken ct = default); + Task GetCustomerHealthAsync(Guid customerId, CancellationToken ct = default); + + + Task> GetCustomersByClientIdAsync(Guid clientId, CancellationToken ct = default); + Task CustomerBelongsToClientAsync(Guid customerId, Guid clientId, CancellationToken ct = default); + Task> GetCustomerPlansByClientIdAsync(Guid clientId, CancellationToken ct = default); + + // Workout Plan operations + Task> GetWorkoutPlansByClientIdAsync(Guid clientId, CancellationToken ct = default); + Task GetWorkoutPlanByIdForClientAsync(Guid workoutPlanId, Guid clientId, CancellationToken ct = default); + + // Meal Plan operations + Task> GetMealPlansByClientIdAsync(Guid clientId, CancellationToken ct = default); + Task GetMealPlanByIdForClientAsync(Guid mealPlanId, Guid clientId, CancellationToken ct = default); + + // Customer Plan operations + Task GetCustomerPlanByIdForClientAsync(Guid customerPlanId, Guid clientId, CancellationToken ct = default); + // Full entity (not the DTO above) — needed by CustomerSubscriptionFactory, which reads + // DurationMode/ValidTo/InstallmentCount/HasOnboardingFee that the DTO doesn't carry. + Task GetCustomerPlanEntityForClientAsync(Guid customerPlanId, Guid clientId, CancellationToken ct = default); + + // Change-membership-plan / unassign-membership operations + Task GetActiveMembershipForCustomerAsync(Guid customerId, CancellationToken ct = default); + // Deactivates a whole subscription and its members/subitems/installments. + Task DeactivateSubscriptionAsync(Guid subscriptionId, CancellationToken ct = default); + // Deactivates a single member's row within a (possibly still-active) subscription. + Task DeactivateSubscriptionMemberAsync(Guid subscriptionMemberId, CancellationToken ct = default); + // Any membership (assigned or not) for assign/unassign to act on. + Task GetMembershipForCustomerAsync(Guid customerId, CancellationToken ct = default); + // Toggles IsPlanAssigned without touching IsActive, so the member stays listed. + Task SetMembershipPlanAssignedAsync( + Guid subscriptionId, bool assigned, bool wholeSubscription, Guid subscriptionMemberId, + CancellationToken ct = default); + // Deactivates oldMembership's subscription (if primary) or just their own row (if not), + // then persists newAggregate as the customer's new subscription. + Task ReplaceCustomerSubscriptionAsync(SubscriptionMember? oldMembership, CustomerSubscriptionAggregate newAggregate, CancellationToken ct = default); + + // Client Package operations + Task> GetPackageListAsync(CancellationToken ct = default); + Task GetPackageByIdAsync(Guid id, CancellationToken ct = default); + Task> GetActiveGymsAsync(CancellationToken ct = default); + Task> GetCardsByClientIdAsync(Guid clientId, CancellationToken ct = default); + Task> GetCardsByCustomerForClientAsync(Guid customerId, Guid clientId, CancellationToken ct = default); + } +} diff --git a/Interfaces/IClientStatRepository.cs b/Interfaces/IClientStatRepository.cs new file mode 100644 index 0000000..205a3dc --- /dev/null +++ b/Interfaces/IClientStatRepository.cs @@ -0,0 +1,21 @@ +using Fitness_core_BE.DTOs.Stats; +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface IClientStatRepository + { + Task GetTotalCustomerCountAsync(Guid clientId, CancellationToken ct = default); + Task GetMonthlyCustomerCountAsync(Guid clientId, CancellationToken ct = default); + Task GetTotalWorkoutPlanCountAsync(Guid clientId, CancellationToken ct = default); + Task GetTotalMealPlanCountAsync(Guid clientId, CancellationToken ct = default); + Task GetLastMonthCustomerCountAsync(Guid clientId, CancellationToken ct = default); + Task> GetLastFiveAttendancesAsync(Guid clientId, CancellationToken ct = default); + Task> GetLastFivePaymentsAsync(Guid clientId, CancellationToken ct = default); + Task GetLastMonthRevenueAsync(Guid clientId, CancellationToken ct = default); + Task> GetMonthlyRevenueVariationAsync(Guid clientId, DateTime? from = null, DateTime? to = null, CancellationToken ct = default); + Task GetTotalRevenueAsync(Guid clientId, CancellationToken ct = default); + Task GetClientStatSummaryAsync(Guid clientId, DateTime? from = null, DateTime? to = null, CancellationToken ct = default); + Task GetExpiringSoonCountAsync(Guid clientId, DateTime from, DateTime to, CancellationToken ct = default); + } +} diff --git a/Interfaces/ICustomerInvoiceRepository.cs b/Interfaces/ICustomerInvoiceRepository.cs new file mode 100644 index 0000000..31782b3 --- /dev/null +++ b/Interfaces/ICustomerInvoiceRepository.cs @@ -0,0 +1,53 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface ICustomerInvoiceRepository + { + // carriedOverBalance: unpaid amount rolled forward from a prior subscription period + // (renewal). includeOnboardingFee: false for renewals -- it's a one-time fee and would + // otherwise re-bill every cycle. + Task CreateCustomerInvoiceFromSubscription( + Guid subscriptionId, bool chargePayment = true, decimal carriedOverBalance = 0m, bool includeOnboardingFee = true); + + // Guard-free sibling used only by the daily renewal sweep -- see the implementation for + // why CreateCustomerInvoiceFromSubscription's idempotency guard doesn't apply here. + Task CreateRenewalInvoiceAsync( + Guid subscriptionId, decimal carriedOverBalance, CancellationToken ct = default); + Task GetInvoiceBySubscriptionIdAsync(Guid subscriptionId); + Task GetCustomerInvoiceById(Guid customerInvoiceId); + Task> GetCustomerInvoicesByCustomerId(Guid customerId); + Task> GetCustomerInvoicesByClientId(Guid clientId); + Task UpdateCustomerInvoiceStatus(Guid customerInvoiceId, string status); + + // Renewal-approval workflow. + Task> GetUnapprovedInvoicesByClientIdAsync(Guid clientId); + + // True when this subscription has a renewal invoice awaiting approval -- used to tell a + // genuinely legacy "no invoice yet" subscription (safe to self-heal) apart from one whose + // only invoices are unapproved/RolledOver right now (not safe: creating a fresh invoice + // there would silently duplicate a renewal already in flight). + Task HasPendingApprovalInvoiceAsync(Guid subscriptionId); + Task> GetLatestApprovedInvoicePerCustomerAsync(Guid clientId); + Task RepointAndRecalculateInvoiceAsync(Guid customerInvoiceId, Guid newSubscriptionId, CancellationToken ct = default); + + // Sum of each invoice's "Previous Balance" line item (0 if it has none) -- the portion of + // Amount that's carried-forward debt rather than this cycle's own charge. Batched so list + // endpoints (e.g. the approvals queue) don't issue one query per row. + Task> GetCarriedForwardAmountsAsync(List invoiceIds); + + // A standalone charge on top of an existing subscription (e.g. an extra fee added + // mid-cycle), separate from the subscription's original CreateCustomerInvoiceFromSubscription + // invoice which is idempotent per subscription and won't pick up items added later. + Task CreateSupplementalInvoiceAsync(Guid subscriptionId, string itemName, string? itemDescription, decimal amount); + + // Plan-change settlement: `amount` is the signed diff between what the new plan costs + // and what the customer already paid toward the old one (positive = they owe more, + // negative = they overpaid and get a credit). Nets out any of the customer's still-open + // Credit invoices first, same as every other invoice-creation path. + Task CreatePlanChangeInvoiceAsync( + Guid subscriptionId, string itemName, decimal amount, CancellationToken ct = default); + + Task GetApprovedPaymentTotalAsync(Guid invoiceId, CancellationToken ct = default); + } +} diff --git a/Interfaces/ICustomerNotificationRepository.cs b/Interfaces/ICustomerNotificationRepository.cs new file mode 100644 index 0000000..368c110 --- /dev/null +++ b/Interfaces/ICustomerNotificationRepository.cs @@ -0,0 +1,14 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface ICustomerNotificationRepository + { + Task CreateAsync(CustomerNotification notification, CancellationToken ct = default); + Task CreateManyAsync(IEnumerable notifications, CancellationToken ct = default); + Task> GetByCustomerIdAsync(Guid customerId, CancellationToken ct = default); + Task GetByIdAsync(Guid customerNotificationId, Guid customerId, CancellationToken ct = default); + Task GetCustomerPushTokenAsync(Guid customerId, CancellationToken ct = default); + Task> GetCustomerPushTokensAsync(IEnumerable customerIds, CancellationToken ct = default); + } +} diff --git a/Interfaces/ICustomerPlanRepository.cs b/Interfaces/ICustomerPlanRepository.cs new file mode 100644 index 0000000..649bf24 --- /dev/null +++ b/Interfaces/ICustomerPlanRepository.cs @@ -0,0 +1,18 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface ICustomerPlanRepository + { + Task> GetAllCustomerPlans(bool includeInactive = false, Guid? clientId = null); Task GetCustomerPlanById(Guid customerPlanId); + Task CreateCustomerPlan(CustomerPlan customerPlan); + Task UpdateCustomerPlan(CustomerPlan customerPlan); + + // Status-only toggle. Deliberately separate from UpdateCustomerPlan: reactivating a + // plan must not re-run the whole cross-field configuration validation, so an older + // plan whose config predates a rule can still be switched back on. + Task SetCustomerPlanActive(Guid customerPlanId, Guid clientId, bool isActive); + + Task DeleteCustomerPlan(Guid customerPlanId, bool softDelete = true); + } +} diff --git a/Interfaces/ICustomerRepository.cs b/Interfaces/ICustomerRepository.cs new file mode 100644 index 0000000..8f94b63 --- /dev/null +++ b/Interfaces/ICustomerRepository.cs @@ -0,0 +1,76 @@ +using Fitness_core_BE.DTOs.Meal; +using Fitness_core_BE.DTOs.Workout; +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface ICustomerRepository + { + Task GetCustomerById(Guid customerId); + + // Central dedupe lookup: is this phone/email an existing Customer (active or not)? + // Matches on Customer's own PrimaryPH/SecondaryPH/Email as well as the linked User's + // PhoneNumber/Email, and deliberately does not filter by IsActive — an inactive + // customer's identity still counts as "already exists" so no duplicate central + // record gets created for the same person. + Task GetCustomerByIdentifier(string identifier); + + Task CreateCustomer(Customer customer); + Task UpdateCustomer(Guid customerId, Customer customer); + + /// + /// Ensures this tenant DB has the TenantCustomerLink anchor row every tenant-side + /// FK (subscriptions, cards, attendance, ...) points at. Idempotent: refreshes the + /// cached display fields and re-activates the link when the row already exists, so + /// it is safe to call on both first-time creation and re-enrolment. + /// + Task EnsureTenantCustomerLink(Customer customer); + + // Own-gym duplicate check: does THIS tenant already have an active customer whose + // cached phone/email matches? Scoped by construction (TenantDbContext is already this + // gym's own schema) -- no clientId parameter needed. + Task FindActiveLinkByIdentifierAsync(string identifier); + + Task DeleteCustomer(Guid customerId); + // Client-initiated delete: deactivates only this customer's presence at the calling + // client's gym (subscriptions/members/plan assignments scoped to clientId). The shared + // central Customer record is never touched — the same person may be an active member + // at a different gym. + Task DeactivateCustomerForClient(Guid customerId, Guid clientId); + Task> GetAllClentShopsAsync(); + Task GetCustomerClientId(Guid customerId); + Task GetActiveMealPlanAsync(Guid? clientId = null, Guid? cusId = null); + Task GetActiveWorkOutAsync(Guid? clientId = null, Guid? cusId = null); + Task> GetFollowedMealPlansByCustomerIdAsync(Guid customerId); + // All of the customer's own AI-generated meal plans (IsTemplate=false), regardless of + // whether they're currently followed — distinct from admin/client-authored templates. + Task> GetMyMealPlansByCustomerIdAsync(Guid customerId); + Task GetMealPlanByIdForCustomerAsync(Guid mealPlanId); + Task> GetFollowedWorkoutPlansByCustomerIdAsync(Guid customerId); + Task> GetMyWorkoutPlansByCustomerIdAsync(Guid customerId); + Task GetWorkoutPlanByIdForCustomerAsync(Guid workoutPlanId, Guid customerId); + Task FollowMealPlan(Guid customerId, Guid mealPlanId); + Task FollowWorkoutPlan(Guid customerId, Guid workOutPlanId); + // Active (IsActive) CustomerWorkout join row for this customer + plan, regardless of + // whether it's the currently-followed one — used to scope per-exercise completion. + Task GetActiveCustomerWorkoutAsync(Guid customerId, Guid workoutPlanId); + Task SetExerciseCompletionAsync(Guid customerWorkoutId, Guid workoutExerciseId, bool completed); + Task GetClientBizDataAsync(Guid ClientBizId); + Task GetCusHealthRec(Guid cusID); + Task> GetCusProgressRec(Guid cusID); + Task CreateCusHealthRecAsync(CustomerHealth customerHealth); + Task UpdateCusHealthRecAsync(CustomerHealth NewcustomerHealth); + Task CreateCustomerTargetAsync(CustomerTarget customerTarget); + Task GetLatestCustomerTargetAsync(Guid customerId); + Task> GetCustomerTargetsAsync(Guid customerId); + Task UpdateLatestCustomerTargetNutritionsAsync(Guid customerId, string predictedNutritionsJson); + Task>> GetCustomerProgressGroupedByGoalAsync(Guid customerId); + Task UpdateWeeklyProgressAsync(Guid customerId, Guid customerTargetId, decimal weight, DateTime? recordedAt); + Task> GetWeeklyProgressForCustomerAsync(Guid customerId, Guid customerTargetId); + Task VerifyCustomer(Guid userId, string type, string identifire); + Task IsverifiedCus(Guid cusId); + Task> GetCardsByCustomerIdAsync(Guid customerId, CancellationToken ct = default); + Task GetCardByIdAsync(Guid cardId, CancellationToken ct = default); + Task AddCardAsync(CustomerCards card, CancellationToken ct = default); + } +} diff --git a/Interfaces/ICustomerSubscriptionRepository.cs b/Interfaces/ICustomerSubscriptionRepository.cs new file mode 100644 index 0000000..3ec887f --- /dev/null +++ b/Interfaces/ICustomerSubscriptionRepository.cs @@ -0,0 +1,53 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface ICustomerSubscriptionRepository + { + Task AddCustomerSubscriptionAsync( + CustomerSubscription subscription, + CustomerSubItem initialSubItem, + SubscriptionMember primaryMember, + IReadOnlyCollection installments, + CancellationToken ct = default); + Task GetPlanByIdAsync(Guid customerPlanId, CancellationToken ct = default); + Task IsActiveCustomerAsync(Guid customerId, CancellationToken ct = default); + Task> GetSubItemsAsync( + Guid subscriptionId, + bool activeOnly, + bool newestFirst, + CancellationToken ct = default); + Task AddSubItemAsync(CustomerSubItem subItem, CancellationToken ct = default); + Task GetLatestInvoiceAsync(Guid subscriptionId, CancellationToken ct = default); + Task GetApprovedPaymentTotalAsync(Guid invoiceId, CancellationToken ct = default); + Task GetCardByIdAsync(Guid cardId, CancellationToken ct = default); + Task GetCustomerSubscriptionById(Guid subscriptionId); + Task> GetCustomerSubscriptionsByCustomerId(Guid customerId); + Task GetActiveSubscriptionByCustomerId(Guid customerId); + + // Whether this customer is currently an active member (primary OR secondary/group) of + // ANY subscription at this gym. GetActiveSubscriptionByCustomerId only sees subscriptions + // where they're the primary -- this also catches a group co-member, so "one customer, one + // plan at a time" can be enforced everywhere a plan gets assigned, not just the primary + // path. + Task HasAnyActiveSubscriptionAsync(Guid customerId, CancellationToken ct = default); + + Task AddSubscriptionMemberAsync(SubscriptionMember member); + Task GetActiveMemberCountAsync(Guid subscriptionId); + Task GetSubscriptionMemberAsync(Guid subscriptionMemberId); + Task GetActiveSubscriptionMemberByCustomerAsync(Guid subscriptionId, Guid customerId); + Task> GetSubscriptionsForCustomerAsync(Guid customerId); + Task> GetInstallmentsAsync(Guid subscriptionId); + + // Renewal-approval workflow (SubscriptionRenewalApprovalService.ApproveAsync): advances + // RenewalDate only -- ExpiryDate is never touched again after creation. + Task AdvanceRenewalDateAsync(Guid subscriptionId, DateTime newRenewalDate, CancellationToken ct = default); + + // Removes the subscription's current-cycle installment rows before a fresh schedule is + // added for the newly-approved cycle. Hard delete, not soft-deactivate: a unique index on + // (FKSubscriptionId, InstallmentNo) means a soft-deactivated row would still collide with + // the new cycle's schedule restarting at InstallmentNo 1 on the same subscription. + Task DeactivatePriorInstallmentsAsync(Guid subscriptionId, CancellationToken ct = default); + Task AddInstallmentsAsync(IReadOnlyCollection installments, CancellationToken ct = default); + } +} diff --git a/Interfaces/IDeviceCommandRepo.cs b/Interfaces/IDeviceCommandRepo.cs new file mode 100644 index 0000000..925072e --- /dev/null +++ b/Interfaces/IDeviceCommandRepo.cs @@ -0,0 +1,36 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface IDeviceCommandRepo + { + Task AddAsync(DeviceCommand command); + Task> GetPendingByClientAsync(Guid clientId); + Task GetByIdAsync(Guid commandId); + Task IsQueuedAsync( + Guid bindingId, + string commandType, + int desiredVersion, + CancellationToken ct = default); + Task> GetPendingStateCommandsAsync( + Guid bindingId, + CancellationToken ct = default); + Task> GetPendingFacePhotoCommandsAsync( + Guid bindingId, + CancellationToken ct = default); + Task> GetExhaustedLeasesAsync( + Guid clientId, + DateTime now, + CancellationToken ct = default); + Task> GetLeaseableCommandsAsync( + Guid clientId, + Guid agentId, + DateTime now, + int take, + CancellationToken ct = default); + Task GetForAgentResultAsync( + Guid commandId, + CancellationToken ct = default); + Task UpdateStatusAsync(DeviceCommand command, string status, string? resultJson = null, DateTime? processedAt = null); + } +} diff --git a/Interfaces/IDocumentRepository.cs b/Interfaces/IDocumentRepository.cs new file mode 100644 index 0000000..0dd5bdf --- /dev/null +++ b/Interfaces/IDocumentRepository.cs @@ -0,0 +1,13 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface IDocumentRepository + { + Task UploadDocumentAsync(UserDocument userDocument); + Task GetLastSequnce(); + Task> GetDocumentsByUserId(Guid userId); + Task GetDocumentById(int documentId); + Task RemoveDocument(int documentId); + } +} diff --git a/Interfaces/IFacilityRepository.cs b/Interfaces/IFacilityRepository.cs new file mode 100644 index 0000000..8ee063d --- /dev/null +++ b/Interfaces/IFacilityRepository.cs @@ -0,0 +1,19 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface IFacilityRepository + { + Task> GetAllFacilities(Guid clientId, bool includeInactive = false); + Task GetFacilityById(Guid facilityId); + Task CreateFacility(Facility facility); + Task UpdateFacility(Facility facility); + Task SetFacilityActive(Guid facilityId, Guid clientId, bool isActive); + + Task CreateFacilitySession(FacilitySession session); + Task GetFacilitySessionById(Guid facilitySessionId); + Task> GetSessions(Guid clientId, Guid? facilityId, DateTime fromUtc, DateTime toUtc); + Task GetActiveBookingCount(Guid facilitySessionId); + Task CancelFacilitySession(Guid facilitySessionId, Guid clientId); + } +} diff --git a/Interfaces/IFeedRepository.cs b/Interfaces/IFeedRepository.cs new file mode 100644 index 0000000..2c0fb21 --- /dev/null +++ b/Interfaces/IFeedRepository.cs @@ -0,0 +1,28 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface IFeedRepository + { + Task GetFeedPostByIdAsync(Guid creatorId, Guid postId); + Task> GetFeedListAsync(int skip, int take); + Task IsLikedAsync(Guid postId, Guid userId); + Task AddLikeAsync(Guid postId, Guid userId); + Task RemoveLikeAsync(Guid postId, Guid userId); + Task CreateFeedAsync(FeedPost feedPost, CancellationToken ct = default); + Task GetLikeCountAsync(Guid postId); + Task> GetFeedByClientAsync(Guid clientId); + Task<(Guid? ClientId, List Posts)> GetFeedForCustomerAsync( + Guid customerUserId, + int skip, + int take, + CancellationToken ct = default); + Task DeletePostPostIdAsync(Guid creatorId, Guid postId); + + // Resolves a feed poster's user id to the gym (Client) they own, then returns the ids of + // all customers with an active subscription at that gym — used to fan out a push + // notification to every member when the gym owner creates a post. Returns an empty list + // for staff-authored posts or any poster who isn't a Client owner. + Task> GetActiveCustomerIdsByPosterUserIdAsync(Guid posterUserId, CancellationToken ct = default); + } +} diff --git a/Interfaces/IGymRoleRepository.cs b/Interfaces/IGymRoleRepository.cs new file mode 100644 index 0000000..d5871eb --- /dev/null +++ b/Interfaces/IGymRoleRepository.cs @@ -0,0 +1,20 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface IGymRoleRepository + { + Task> GetRolesByClientId(Guid clientId); + Task GetRoleById(Guid gymRoleId); + Task CreateRole(GymRole role); + Task UpdateRole(GymRole role); + Task DeleteRole(GymRole role); + Task ReplacePermissions(Guid gymRoleId, IEnumerable navKeys); + Task> GetPermissionKeys(Guid gymRoleId); + + // Returns false if the staff member doesn't exist or doesn't belong to clientId (tenant isolation), + // or if gymRoleId is supplied but doesn't belong to clientId either. + Task AssignStaffRole(Guid staffId, Guid clientId, Guid? gymRoleId); + + } +} diff --git a/Interfaces/IMealRepository.cs b/Interfaces/IMealRepository.cs new file mode 100644 index 0000000..6f98ced --- /dev/null +++ b/Interfaces/IMealRepository.cs @@ -0,0 +1,34 @@ +using Fitness_core_BE.Models; +using Fitness_core_BE.DTOs.Meal; + +namespace Fitness_core_BE.Interfaces +{ + public interface IMealRepository + { + // Meal Category operations + Task> GetMealCategoryListAsync(CancellationToken ct = default); + Task MealCategoryExistsAsync(Guid mealCategoryId, CancellationToken ct = default); + Task CreateMealCategoryAsync(MealCategory mealCategory, CancellationToken ct = default); + + // Meal Plan operations + Task> GetMealPlanListAsync(string userType, Guid clientId, CancellationToken ct = default); + Task GetMealPlanByIdAsync(Guid id, CancellationToken ct = default); + Task> GetMealPlansByCustomerIdAsync(Guid customerId, CancellationToken ct = default); + Task GetMealPlanEntityByIdAsync(Guid id, CancellationToken ct = default); + Task CreateMealPlanAsync(MealPlan mealPlan, CancellationToken ct = default); + Task UpdateMealPlanAsync(MealPlan mealPlan, CancellationToken ct = default); + Task DeleteMealPlanAsync(Guid id, CancellationToken ct = default); + + // Meal Item operations + Task> GetMealsByMealPlanIdAsync(Guid mealPlanId, CancellationToken ct = default); + Task GetMealByIdAsync(Guid id, CancellationToken ct = default); + Task CreateMealAsync(Meal mealItem, CancellationToken ct = default); + Task UpdateMealAsync(Meal mealItem, CancellationToken ct = default); + Task DeleteMealAsync(Guid id, CancellationToken ct = default); + + Task AssignMealToCustomerAsync(CustomerMeal customerMeal, CancellationToken ct = default); + Task UnassignMealFromCustomerAsync(Guid customerMealId, CancellationToken ct = default); + Task> GetCustomerMealsByPlanIdAsync(Guid mealPlanId, CancellationToken ct = default); + Task> GetCustomerMealsByCustomerIdAsync(Guid customerId, CancellationToken ct = default); + } +} diff --git a/Interfaces/INavItemRepository.cs b/Interfaces/INavItemRepository.cs new file mode 100644 index 0000000..9eecac7 --- /dev/null +++ b/Interfaces/INavItemRepository.cs @@ -0,0 +1,9 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface INavItemRepository + { + Task> GetActiveNavItemsOrdered(); + } +} diff --git a/Interfaces/IOverdueLockRepository.cs b/Interfaces/IOverdueLockRepository.cs new file mode 100644 index 0000000..bc872fb --- /dev/null +++ b/Interfaces/IOverdueLockRepository.cs @@ -0,0 +1,21 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface IOverdueLockRepository + { + Task> GetClientsWithOverdueLockPolicyAsync(CancellationToken ct = default); + Task> GetOverdueCustomerIdsAsync( + Guid clientId, + DateTime thresholdDate, + CancellationToken ct = default); + Task GetCustomerByIdAsync(Guid customerId, CancellationToken ct = default); + Task IsCustomerAccessDisabledAsync( + Guid clientId, + Guid userId, + CancellationToken ct = default); + Task> GetCustomersLockedForOverdueAsync( + Guid clientId, + CancellationToken ct = default); + } +} diff --git a/Interfaces/IPairingCodeRepo.cs b/Interfaces/IPairingCodeRepo.cs new file mode 100644 index 0000000..6e96986 --- /dev/null +++ b/Interfaces/IPairingCodeRepo.cs @@ -0,0 +1,11 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface IPairingCodeRepo + { + Task AddAsync(PairingCode pairingCode); + Task GetActiveByCodeAsync(string code); + Task MarkUsedAsync(PairingCode pairingCode); + } +} diff --git a/Interfaces/IPaymentRepository.cs b/Interfaces/IPaymentRepository.cs new file mode 100644 index 0000000..3e7be3e --- /dev/null +++ b/Interfaces/IPaymentRepository.cs @@ -0,0 +1,14 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface IPaymentRepository + { + Task CreatePaymentAttemptAsync(PaymentTransaction attempt); + Task GetPaymentByIdAsync(Guid attemptId); + Task> GetPaymentsByCustomerInvoiceIdAsync(Guid customerInvoiceId); + Task> GetPaymentsByClientInvoiceIdAsync(Guid clientInvoiceId); + Task UpdatePaymentAttemptAsync(PaymentTransaction attempt); + Task GetApprovedPaidAmountAsync(Guid customerInvoiceId); + } +} diff --git a/Interfaces/IPaymentsEnablementRepository.cs b/Interfaces/IPaymentsEnablementRepository.cs new file mode 100644 index 0000000..72f3769 --- /dev/null +++ b/Interfaces/IPaymentsEnablementRepository.cs @@ -0,0 +1,19 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface IPaymentsEnablementRepository + { + Task GetWalletByClientIdAsync(Guid clientId, CancellationToken ct = default); + Task> GetLedgerAsync(Guid walletId, CancellationToken ct = default); + + Task GetKycByClientIdAsync(Guid clientId, CancellationToken ct = default); + Task GetKycForUpdateAsync(Guid clientId, CancellationToken ct = default); + Task CreateKycAsync(ClientPayoutKyc kyc, CancellationToken ct = default); + + Task<(List Items, int Total)> ListKycAsync(string? status, CancellationToken ct = default); + + Task CreateWalletAsync(ClientWallet wallet, CancellationToken ct = default); + Task CreateClientCardAsync(ClientCard card, CancellationToken ct = default); + } +} diff --git a/Interfaces/IPlaceRepo.cs b/Interfaces/IPlaceRepo.cs new file mode 100644 index 0000000..cf6e568 --- /dev/null +++ b/Interfaces/IPlaceRepo.cs @@ -0,0 +1,25 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface IPlaceRepo + { + Task> GetAllPlaces(Guid clientId, bool includeInactive = false); + Task GetPlaceById(Guid placeId); + Task CreatePlace(Place place); + Task UpdatePlace(Place place); + Task SetPlaceActive(Guid placeId, Guid clientId, bool isActive); + + Task GetDeviceByIdForClientAsync(Guid deviceId, Guid clientId); + Task> GetDevicesByPlaceIdsAsync(List placeIds); + Task IsDeviceAssignedToAnotherPlaceAsync(Guid deviceId, Guid placeId); + Task AssignDeviceAsync(Guid placeId, Guid deviceId); + Task UnassignDeviceAsync(Guid placeId); + + // CustomerPlan <-> Place (many-to-many). Replace-all semantics, like a tags field. + Task SetPlanPlacesAsync(Guid customerPlanId, List placeIds); + Task> GetPlaceIdsForPlanAsync(Guid customerPlanId); + Task>> GetPlaceIdsByPlanIdsAsync(List customerPlanIds); + Task> GetPlaceNamesByIdsAsync(List placeIds); + } +} diff --git a/Interfaces/IPlatformBillingRepository.cs b/Interfaces/IPlatformBillingRepository.cs new file mode 100644 index 0000000..b08d768 --- /dev/null +++ b/Interfaces/IPlatformBillingRepository.cs @@ -0,0 +1,66 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface IPlatformBillingRepository + { + Task> GetPricingPlansAsync(CancellationToken ct = default); + Task GetPackageByIdAsync(Guid packageId, CancellationToken ct = default); + Task AddPackageAsync(ClientPackage package, CancellationToken ct = default); + Task> GetOtherDefaultPackagesAsync( + Guid exceptPackageId, + CancellationToken ct = default); + Task GetDefaultPackageAsync(CancellationToken ct = default); + Task GetActivePackageForClientAsync( + Guid clientId, + CancellationToken ct = default); + + Task GetClientByIdAsync(Guid clientId, CancellationToken ct = default); + Task> GetActiveClientSubscriptionsAsync( + Guid clientId, + CancellationToken ct = default); + Task GetActiveClientSubscriptionAsync( + Guid clientId, + CancellationToken ct = default); + Task AddClientSubscriptionAsync( + ClientSubscription subscription, + CancellationToken ct = default); + + Task> GetActiveDeviceBillingsAsync( + Guid clientId, + CancellationToken ct = default); + Task GetLatestActiveDeviceBillingAsync( + Guid clientId, + CancellationToken ct = default); + Task AddDeviceBillingAsync( + ClientDeviceBilling billing, + CancellationToken ct = default); + + Task HasActiveDeviceAsync(Guid clientId, CancellationToken ct = default); + Task> GetMonthlyBillingCandidatesAsync( + DateTime startOfMonth, + CancellationToken ct = default); + + Task> GetInvoicesByStatusAsync( + string status, + bool newestFirst, + CancellationToken ct = default); + Task GetInvoiceWithClientAsync( + Guid invoiceId, + bool includeBusinesses, + CancellationToken ct = default); + Task GetClientCardAsync(Guid clientId, CancellationToken ct = default); + Task> GetClientCardsAsync(Guid clientId, CancellationToken ct = default); + Task GetClientCardByTokenAsync(Guid clientId, string cardToken, CancellationToken ct = default); + Task AddInvoiceAsync( + ClientInvoice invoice, + IReadOnlyCollection items, + CancellationToken ct = default); + + Task GetDevicePricingConfigAsync( + CancellationToken ct = default); + Task AddDevicePricingConfigAsync( + DevicePricingConfig config, + CancellationToken ct = default); + } +} diff --git a/Interfaces/IProfitLossRepository.cs b/Interfaces/IProfitLossRepository.cs new file mode 100644 index 0000000..2394242 --- /dev/null +++ b/Interfaces/IProfitLossRepository.cs @@ -0,0 +1,24 @@ +using Fitness_core_BE.DTOs.Ledger; +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface IProfitLossRepository + { + Task> GetDerivedEntriesAsync( + Guid clientId, + DateTime fromUtc, + DateTime toUtc, + CancellationToken ct = default); + + Task> GetManualEntriesAsync( + Guid clientId, + DateTime fromUtc, + DateTime toUtc, + CancellationToken ct = default); + + Task GetManualEntryByIdAsync(Guid id, CancellationToken ct = default); + Task AddManualEntryAsync(LedgerEntry entry, CancellationToken ct = default); + void RemoveManualEntry(LedgerEntry entry); + } +} diff --git a/Interfaces/IStaffRepository.cs b/Interfaces/IStaffRepository.cs new file mode 100644 index 0000000..65c5284 --- /dev/null +++ b/Interfaces/IStaffRepository.cs @@ -0,0 +1,20 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface IStaffRepository + { + Task> GetAllStaff(bool includeInactive = false); + Task GetStaffById(Guid staffId); + Task> GetStaffByClientId(Guid clientId, bool includeInactive = false); + Task CreateStaff(Staff staff, User user); + Task UpdateStaff(Staff staff); + Task DeleteStaff(Guid staffId, bool softDelete = true); + + // Basic HRM/payroll (req 5). + Task GetActivePayRateAsync(Guid staffId); + Task AddPayRateAsync(StaffPayRate rate); + Task> GetPaymentsAsync(Guid staffId); + Task AddPaymentAsync(StaffPayment payment); + } +} diff --git a/Interfaces/ITermsRepository.cs b/Interfaces/ITermsRepository.cs new file mode 100644 index 0000000..699d9e5 --- /dev/null +++ b/Interfaces/ITermsRepository.cs @@ -0,0 +1,14 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface ITermsRepository + { + Task GetActiveByClientIdAsync(Guid clientId); + Task> GetActiveForUpdateAsync(Guid clientId); + Task GetLatestVersionAsync(Guid clientId); + Task AddTermsAsync(ClientTerms terms); + Task AddAcceptanceAsync(CustomerTermsAcceptance acceptance); + Task GetAcceptanceAsync(Guid cusId, Guid clientId, int version); + } +} diff --git a/Interfaces/ITicketRepository.cs b/Interfaces/ITicketRepository.cs new file mode 100644 index 0000000..aa3b806 --- /dev/null +++ b/Interfaces/ITicketRepository.cs @@ -0,0 +1,26 @@ +using Fitness_core_BE.Models; +using Fitness_core_BE.DTOs.Ticket; + +namespace Fitness_core_BE.Interfaces +{ + public interface ITicketRepository + { + // Ticket operations + Task GetTicketCatId(string type); + Task> GetTicketListAsync(CancellationToken ct = default); + Task> GetTicketByUserIdAsync(Guid userId, CancellationToken ct = default); + Task GetByTicketIdAsync(Guid id, CancellationToken ct = default); + Task CreateTicketAsync(Ticket ticket, CancellationToken ct = default); + Task UpdateTicketAsync(TicketDetailDto ticket, CancellationToken ct = default); + Task DeleteAsync(Guid id, CancellationToken ct = default); + + // Ticket category operations + Task> GetTicketCategoryListAsync(CancellationToken ct = default); + Task GetTicketCategoryByIdAsync(Guid id, CancellationToken ct = default); + + // Ticket message operations + Task SendMessageAsync(TicketMessage ticketMessage, CancellationToken ct = default); + Task SubmitTicketFeedbackAsync(Guid ticketId, int rating, string? feedback, CancellationToken ct = default); + Task UpdateTicketStatusAsync(Guid ticketId, Fitness_core_BE.Models.Enums.TicketStatusEnum statusCode); + } +} diff --git a/Interfaces/ITimeBankCardRepository.cs b/Interfaces/ITimeBankCardRepository.cs new file mode 100644 index 0000000..d0214a7 --- /dev/null +++ b/Interfaces/ITimeBankCardRepository.cs @@ -0,0 +1,13 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface ITimeBankCardRepository + { + Task CreateTimeBankCard(TimeBankCard card); + Task GetById(Guid timeBankCardId); + Task GetByRfidTagId(string rfidTagId); + Task> GetByCustomerId(Guid customerId); + Task AddUsageLog(TimeBankCardUsageLog log); + } +} diff --git a/Interfaces/IWorkoutRepository.cs b/Interfaces/IWorkoutRepository.cs new file mode 100644 index 0000000..602a4d1 --- /dev/null +++ b/Interfaces/IWorkoutRepository.cs @@ -0,0 +1,34 @@ +using Fitness_core_BE.Models; +using Fitness_core_BE.DTOs.Workout; + +namespace Fitness_core_BE.Interfaces +{ + public interface IWorkoutRepository + { + // Workout Category operations + Task> GetWorkoutCategoryListAsync(CancellationToken ct = default); + Task WorkoutCategoryExistsAsync(Guid workoutCategoryId, CancellationToken ct = default); + Task CreateWorkoutCategoryAsync(WorkoutCategory workoutCategory, CancellationToken ct = default); + + // Workout Plan operations + Task> GetWorkoutPlanListAsync(string userType, Guid clientId, CancellationToken ct = default); + Task GetWorkoutPlanByIdAsync(Guid id, CancellationToken ct = default); + Task> GetWorkoutPlansByCustomerIdAsync(Guid customerId, CancellationToken ct = default); + Task GetWorkoutPlanEntityByIdAsync(Guid id, CancellationToken ct = default); + Task CreateWorkoutPlanAsync(WorkoutPlan workoutPlan, CancellationToken ct = default); + Task UpdateWorkoutPlanAsync(WorkoutPlan workoutPlan, CancellationToken ct = default); + Task DeleteWorkoutPlanAsync(Guid id, CancellationToken ct = default); + + // Workout Item operations + Task> GetWorkoutItemsByWorkoutPlanIdAsync(Guid workoutPlanId, CancellationToken ct = default); + Task GetWorkoutItemByIdAsync(Guid id, CancellationToken ct = default); + Task CreateWorkoutItemAsync(WorkoutItem workoutItem, CancellationToken ct = default); + Task UpdateWorkoutItemAsync(WorkoutItem workoutItem, CancellationToken ct = default); + Task DeleteWorkoutItemAsync(Guid id, CancellationToken ct = default); + + Task AssignWorkoutToCustomerAsync(CustomerWorkout customerWorkout, CancellationToken ct = default); + Task UnassignWorkoutFromCustomerAsync(Guid customerWorkoutId, CancellationToken ct = default); + Task> GetCustomerWorkoutsByPlanIdAsync(Guid workoutPlanId, CancellationToken ct = default); + Task> GetCustomerWorkoutsByCustomerIdAsync(Guid customerId, CancellationToken ct = default); + } +} diff --git a/Interfaces/Services/IAccessDeviceService.cs b/Interfaces/Services/IAccessDeviceService.cs new file mode 100644 index 0000000..ef02dee --- /dev/null +++ b/Interfaces/Services/IAccessDeviceService.cs @@ -0,0 +1,47 @@ +using Fitness_core_BE.DTOs; +using Fitness_core_BE.DTOs.AccessDevice; +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces.Services +{ + public interface IAccessDeviceService + { + Task AddDevice(object payload); + Task RemoveDevice(object payload); + Task GeneratePairingCode(object payload); + Task WhiteListMember(Guid memberId); + Task BlackListMember(Guid memberId); + + // Manual/auto lock (req 7) — wraps WhiteListMember/BlackListMember and mirrors the + // result onto Customer.IsLocked/LockReason/LockedAt. + Task LockCustomer(Guid customerId, string reason); + Task UnlockCustomer(Guid customerId); + Task UpdateDeviceProfile(Customer customer); + Task AddUser(CustomerAccess cusDevice); + + // Dedup + persist attendance. Reused by the agent attendance ingestion endpoint. + Task SaveAttendanceRecords(Guid clientId, List attendanceList); + + // Fingerprint-enrolled-directly-on-device reconciliation (req 3). + Task> GetUnlinkedFingerprintsAsync(Guid clientId); + Task LinkFingerprintAsync(Guid clientId, Guid customerId, Guid unlinkedEnrollmentId); + + Task EnsureAccessProfileAsync( + Guid clientId, Guid userId, string personType, string displayName, bool queueProvisioning = true); + Task ProvisionProfilesToDeviceAsync(AccessDevice device); + Task SetUserAccessAsync(Guid clientId, Guid userId, bool enabled, string? reason); + Task SetCustomerAccessAsync(Guid clientId, Guid customerId, bool enabled, string? reason); + + /// + /// Queues a face photo for enrolment into the gym's Hikvision terminals. The image + /// travels in the command payload only -- it is purged once a terminal confirms. + /// + Task SetCustomerFacePhotoAsync(Guid clientId, Guid customerId, string base64Jpeg); + Task SetStaffAccessAsync(Guid clientId, Guid staffId, bool enabled, string? reason); + Task LinkDevicePersonAsync( + Guid clientId, Guid userId, string personType, string displayName, Guid unlinkedEnrollmentId); + Task LinkStaffFingerprintAsync( + Guid clientId, Guid staffId, Guid unlinkedEnrollmentId); + Task GetAccessStatusAsync(Guid clientId, Guid userId); + } +} diff --git a/Interfaces/Services/IActivityLogService.cs b/Interfaces/Services/IActivityLogService.cs new file mode 100644 index 0000000..316f373 --- /dev/null +++ b/Interfaces/Services/IActivityLogService.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Interfaces.Services +{ + public interface IActivityLogService + { + Task LogActivity(object payload, HttpContext httpContext); + Task GetActivityLogs(object payload, HttpContext httpContext); + } +} diff --git a/Interfaces/Services/IAdminClientDirectoryService.cs b/Interfaces/Services/IAdminClientDirectoryService.cs new file mode 100644 index 0000000..9e63215 --- /dev/null +++ b/Interfaces/Services/IAdminClientDirectoryService.cs @@ -0,0 +1,16 @@ +using Fitness_core_BE.Models.IOs; +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Interfaces.Services +{ + public interface IAdminClientDirectoryService + { + Task> ListAsync(ClientDirectoryQuery query); + Task GetByIdAsync(Guid id); + Task GeneratePairingCodeAsync(Guid clientId); + Task GetDevicesAsync(Guid clientId); + // Re-sends the "set your password" welcome email (with a fresh temp password + link) + // for a client whose account has no usable password yet. + Task ResendWelcomeEmailAsync(Guid clientId, HttpContext httpContext); + } +} diff --git a/Interfaces/Services/IAdminSuggestionService.cs b/Interfaces/Services/IAdminSuggestionService.cs new file mode 100644 index 0000000..f372676 --- /dev/null +++ b/Interfaces/Services/IAdminSuggestionService.cs @@ -0,0 +1,11 @@ +using Fitness_core_BE.Models.IOs; + +namespace Fitness_core_BE.Interfaces.Services +{ + public interface IAdminSuggestionService + { + Task ListAsync(); + Task CreateWorkoutAsync(AdminWorkoutSuggestionRequest request, HttpContext httpContext); + Task CreateMealAsync(AdminMealSuggestionRequest request, HttpContext httpContext); + } +} diff --git a/Interfaces/Services/IAgentService.cs b/Interfaces/Services/IAgentService.cs new file mode 100644 index 0000000..0bdb7dd --- /dev/null +++ b/Interfaces/Services/IAgentService.cs @@ -0,0 +1,21 @@ +using Fitness_core_BE.DTOs.Agent; + +namespace Fitness_core_BE.Interfaces.Services +{ + public interface IAgentService + { + // Bootstrap enrollment (no api-key auth). Returns { agentId, apiKey } once. + Task EnrollAsync(EnrollRequest request); + + // Agent-authenticated operations. clientId / agentId are resolved server-side + // from the authenticated agent, never from the request body. + Task RegisterDeviceAsync(Guid clientId, Guid agentId, RegisterDeviceRequest request); + Task ReconcileDeviceInventoryAsync(Guid clientId, Guid agentId, DeviceInventoryRequest request); + Task IngestAttendanceAsync(Guid clientId, Guid agentId, AgentAttendanceRequest request); + Task GetPendingCommandsAsync(Guid clientId, Guid agentId); + Task SubmitCommandResultAsync( + Guid clientId, Guid agentId, Guid commandId, CommandResultRequest request); + Task HeartbeatAsync( + Guid clientId, Guid agentId, AgentHeartbeatRequest request); + } +} diff --git a/Interfaces/Services/IAttendanceService.cs b/Interfaces/Services/IAttendanceService.cs new file mode 100644 index 0000000..8a30d7b --- /dev/null +++ b/Interfaces/Services/IAttendanceService.cs @@ -0,0 +1,9 @@ +namespace Fitness_core_BE.Interfaces.Services +{ + public interface IAttendanceService + { + Task GetAllAttendanceRecords(object payload, HttpContext httpContext); + Task GetAttendanceCounts(object payload, HttpContext httpContext); + Task GetTodayAttendance(object payload, HttpContext httpContext); + } +} diff --git a/Interfaces/Services/IAuthService.cs b/Interfaces/Services/IAuthService.cs new file mode 100644 index 0000000..97653ff --- /dev/null +++ b/Interfaces/Services/IAuthService.cs @@ -0,0 +1,32 @@ +using Fitness_core_BE.DTOs.Auth; +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Interfaces.Services +{ + public interface IAuthService + { + Task SendOtp(object payload, HttpContext httpContext); + Task LoginUser(object payload, HttpContext httpContext); + Task RefreshToken(object payload, HttpContext httpContext); + Task VerifyOTP(object payload, HttpContext httpContext); + // Verifies an OTP and, on success, sets the caller's Customer channel flag + // (IsPrimaryPHVerfied / IsEmailVerfied / IsSecondaryPHVerfied). + Task VerifyOTPForCus(object payload, HttpContext httpContext); + Task IsAvailable(object payload, HttpContext httpContext); + Task GetUserDetails(object payload, HttpContext httpContext); + Task UpdateUser(object payload, HttpContext httpContext); + // Admin/service-initiated update of an arbitrary user's login credential by userId — + // unlike UpdateUser (which patches the caller's own profile from their JWT), this lets + // a client/staff edit patch the login record of the *customer* they're editing. Forwards + // to Auth's existing "adminUpdateUser" function. + Task AdminUpdateUser(object payload, HttpContext httpContext); + Task RegisterUser(object payload, HttpContext httpContext); + Task VerifyOtpForLogin(object payload, HttpContext httpContext); + Task ForgotPassword(object payload, HttpContext httpContext); + Task ResetPassword(object payload, HttpContext httpContext); + Task ResetPasswordWithToken(object payload, HttpContext httpContext); + Task VerifyPassword(object payload, HttpContext httpContext); + Task LogOut(object payload, HttpContext httpContext); + void SetAccessTokenCookie(HttpContext httpContext, string token); + } +} diff --git a/Interfaces/Services/IBookingService.cs b/Interfaces/Services/IBookingService.cs new file mode 100644 index 0000000..44b1e0b --- /dev/null +++ b/Interfaces/Services/IBookingService.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Interfaces.Services +{ + public interface IBookingService + { + /// Logged-in member books a session against their own plan's allowance. + Task CreateMemberBooking(object payload, HttpContext httpContext); + + /// No membership at all -- pay-per-session, identity proof is the returned OTP. + Task CreateGuestBooking(object payload, HttpContext httpContext); + + /// Renders the public, unauthenticated confirmation page a booking's ShareUrl + /// points to -- the "copy the link and share it" receipt. Returns null if not found, so + /// the controller can render a 404 page instead of a JSON error for a browser hit. + Task GetBookingView(object payload); + + /// Staff records a guest booking's payment (cash/bank/at-venue) after the fact. + Task MarkGuestBookingPaid(object payload, HttpContext httpContext); + + /// Staff/door check-in: verifies the OTP and marks the booking checked in. + Task CheckInBooking(object payload, HttpContext httpContext); + + Task CancelBooking(object payload, HttpContext httpContext); + Task GetMyBookings(object payload, HttpContext httpContext); + Task GetSessionBookings(object payload, HttpContext httpContext); + } +} diff --git a/Interfaces/Services/IBootstrapService.cs b/Interfaces/Services/IBootstrapService.cs new file mode 100644 index 0000000..f33ca2c --- /dev/null +++ b/Interfaces/Services/IBootstrapService.cs @@ -0,0 +1,7 @@ +namespace Fitness_core_BE.Interfaces.Services +{ + public interface IBootstrapService + { + Task BootstrapClub(object payload); + } +} diff --git a/Interfaces/Services/IChatService.cs b/Interfaces/Services/IChatService.cs new file mode 100644 index 0000000..efdc6f7 --- /dev/null +++ b/Interfaces/Services/IChatService.cs @@ -0,0 +1,12 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces.Services +{ + public interface IChatService + { + Task GetOrCreateChatAsync(object payload); + Task SendMessageAsync(object payload); + Task GetMessagesAsync(object payload); + Task CanUserAccessChatAsync(Guid chatId, Guid userId); + } +} diff --git a/Interfaces/Services/IClientInvoiceService.cs b/Interfaces/Services/IClientInvoiceService.cs new file mode 100644 index 0000000..652fcfb --- /dev/null +++ b/Interfaces/Services/IClientInvoiceService.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Interfaces.Services +{ + public interface IClientInvoiceService + { + Task CreateClientInvoiceFromSubscription(object payload, HttpContext httpContext); + Task GetClientInvoiceById(object payload, HttpContext httpContext); + Task GetClientInvoicesByClientId(object payload, HttpContext httpContext); + Task GetClientInvoicesByUserId(object payload, HttpContext httpContext); + Task UpdateClientInvoiceStatus(object payload, HttpContext httpContext); + Task CalculateClientInvoiceTotal(object payload, HttpContext httpContext); + } +} diff --git a/Interfaces/Services/IClientPaymentMethodService.cs b/Interfaces/Services/IClientPaymentMethodService.cs new file mode 100644 index 0000000..e507043 --- /dev/null +++ b/Interfaces/Services/IClientPaymentMethodService.cs @@ -0,0 +1,33 @@ +using Fitness_core_BE.DTOs.PlatformBilling; +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Interfaces.Services +{ + // The gym owner's own card(s) on file for their HexaFitness platform subscription, and + // the transaction history behind those charges — backed by Payment-Service via + // IPaymentGatewayClient, scoped to the caller's own Client (see UserRoleHelper.GetClientId). + public interface IClientPaymentMethodService + { + Task StartCardOnboardingAsync( + HttpContext httpContext, + StartCardOnboardingRequest request, + CancellationToken ct = default); + + Task CompleteCardOnboardingAsync( + HttpContext httpContext, + string sessionId, + CancellationToken ct = default); + + Task> ListCardsAsync(HttpContext httpContext, CancellationToken ct = default); + + Task RemoveCardAsync(HttpContext httpContext, string cardToken, CancellationToken ct = default); + + Task SetDefaultCardAsync(HttpContext httpContext, string cardToken, CancellationToken ct = default); + + Task GetTransactionHistoryAsync( + HttpContext httpContext, + int page, + int pageSize, + CancellationToken ct = default); + } +} diff --git a/Interfaces/Services/IClientService.cs b/Interfaces/Services/IClientService.cs new file mode 100644 index 0000000..87bab7e --- /dev/null +++ b/Interfaces/Services/IClientService.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Http; +using Fitness_core_BE.Models.IOs; + +namespace Fitness_core_BE.Interfaces.Services +{ + public interface IClientService + { + Task GetAllCustomers(object payload, HttpContext httpContext); + Task GetClientList(object payload, HttpContext httpContext); + Task GetClientById(object payload, HttpContext httpContext); + Task UpdateGymProfile( + UpdateGymProfileRequest request, HttpContext httpContext); + // Gym-specific Terms & Conditions authoring (client portal). + Task GetMyGymTerms(object payload, HttpContext httpContext); + Task PublishGymTerms(object payload, HttpContext httpContext); + Task UploadGymLogo(IFormFile logo, HttpContext httpContext); + Task GetGymLogo(HttpContext httpContext); + Task SendContactOtp(object payload, HttpContext httpContext); + Task VerifyContactOtp(object payload, HttpContext httpContext); + Task GetCustomerFullDetails(object payload, HttpContext httpContext); + Task GetCustomerMedical(object payload, HttpContext httpContext); + Task GetCustomerDetailsById(object payload, HttpContext httpContext); + Task CreateCustomerWithPlans(object payload, HttpContext httpContext); + Task UpdateCustomerByClient(object payload, HttpContext httpContext); + Task GetCustomersByClientId(object payload, HttpContext httpContext); + // "Parent" path for a plan-less customer: starts a brand new subscription with them + // as primary. Mirrors CreateCustomerWithPlans' plan-assignment step, callable standalone + // for a customer who was created without a plan. + Task AssignPlanToExistingCustomer(Guid customerId, object payload, HttpContext httpContext); + + // Switches a customer with an existing Individual-scope subscription onto a different + // Individual-scope plan: deactivates the old subscription and starts a fresh one. + Task ChangeCustomerMembershipPlan(Guid customerId, Guid newCustomerPlanId, HttpContext httpContext); + Task UnassignCustomerMembership(Guid customerId, HttpContext httpContext); + Task GetCustomerPlansByClientId(object payload, HttpContext httpContext); + Task RegisterClient(object payload, HttpContext httpContext); + Task GetPackageList(object payload, HttpContext httpContext); + Task DeleteCustomerByClient(object payload, HttpContext httpContext); + Task ClientLogOut(object payload, HttpContext httpContext); + Task GetWorkoutPlansByClientId(object payload, HttpContext httpContext); + Task GetWorkoutPlanById(object payload, HttpContext httpContext); + Task GetMealPlansByClientId(object payload, HttpContext httpContext); + Task GetMealPlanById(object payload, HttpContext httpContext); + Task GetAllCustomerPlans(object payload, HttpContext httpContext); + Task GetCustomerPlanById(object payload, HttpContext httpContext); + Task GetCardDetails(object payload, HttpContext httpContext); + // Cards a specific member has saved with this gym (used to offer the auto-charge toggle). + Task GetCustomerCards(object payload, HttpContext httpContext); + + // Auto-lock-after-non-payment settings (req 7) — client-configurable. + Task GetLockPolicy(HttpContext httpContext); + Task UpdateLockPolicy(int? lockAfterDaysOverdue, HttpContext httpContext); + } +} diff --git a/Interfaces/Services/IClientStatService.cs b/Interfaces/Services/IClientStatService.cs new file mode 100644 index 0000000..4f7e6ba --- /dev/null +++ b/Interfaces/Services/IClientStatService.cs @@ -0,0 +1,17 @@ +namespace Fitness_core_BE.Interfaces.Services +{ + public interface IClientStatService + { + Task GetTotalCustomerCount(object payload, HttpContext httpContext); + Task GetMonthlyCustomerCount(object payload, HttpContext httpContext); + Task GetTotalWorkoutPlanCount(object payload, HttpContext httpContext); + Task GetTotalMealPlanCount(object payload, HttpContext httpContext); + Task GetLastMonthCustomerCount(object payload, HttpContext httpContext); + Task GetLastFiveAttendances(object payload, HttpContext httpContext); + Task GetLastFivePayments(object payload, HttpContext httpContext); + Task GetLastMonthRevenue(object payload, HttpContext httpContext); + Task GetMonthlyRevenueVariation(object payload, HttpContext httpContext, DateTime? from = null, DateTime? to = null); + Task GetTotalRevenue(object payload, HttpContext httpContext); + Task GetClientStatSummary(object payload, HttpContext httpContext, DateTime? from = null, DateTime? to = null); + } +} diff --git a/Interfaces/Services/ICustomerInvoiceService.cs b/Interfaces/Services/ICustomerInvoiceService.cs new file mode 100644 index 0000000..58ac993 --- /dev/null +++ b/Interfaces/Services/ICustomerInvoiceService.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Interfaces.Services +{ + public interface ICustomerInvoiceService + { + Task CreateCustomerInvoiceFromSubscription(object payload, HttpContext httpContext); + Task GetCustomerInvoiceById(object payload, HttpContext httpContext); + Task GetCustomerInvoicesByCustomerId(object payload, HttpContext httpContext); + Task GetCustomerInvoicesByClientId(object payload, HttpContext httpContext); + Task UpdateCustomerInvoiceStatus(object payload, HttpContext httpContext); + Task CalculateCustomerInvoiceTotal(object payload, HttpContext httpContext); + } +} diff --git a/Interfaces/Services/ICustomerPlanService.cs b/Interfaces/Services/ICustomerPlanService.cs new file mode 100644 index 0000000..2d3a387 --- /dev/null +++ b/Interfaces/Services/ICustomerPlanService.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Interfaces.Services +{ + public interface ICustomerPlanService + { + Task GetAllCustomerPlans(object payload, HttpContext httpContext); + Task GetCustomerPlanById(object payload, HttpContext httpContext); + Task CreateCustomerPlan(object payload, HttpContext httpContext); + Task UpdateCustomerPlan(object payload, HttpContext httpContext); + + /// Activates or deactivates a plan without touching its configuration. + Task SetCustomerPlanStatus(object payload, HttpContext httpContext); + + Task DeleteCustomerPlan(object payload, HttpContext httpContext); + } +} diff --git a/Interfaces/Services/ICustomerService.cs b/Interfaces/Services/ICustomerService.cs new file mode 100644 index 0000000..23e19d1 --- /dev/null +++ b/Interfaces/Services/ICustomerService.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Interfaces.Services +{ + public interface ICustomerService + { + Task CreateCustomer(object payload, HttpContext httpContext); + Task CustomerExists(object payload, HttpContext httpContext); + // True when the caller's Customer has verified at least one channel (email/phone). + Task IsCustomerVerified(HttpContext httpContext); + Task AssignPlansToCustomer(Guid customerId, object payload, HttpContext httpContext); + Task UpdateCustomer(object payload, HttpContext httpContext); + Task DeleteCustomer(object payload, HttpContext httpContext); + Task GetCustomerMealPlan(object payload, HttpContext httpContext); + Task GetCustomerMealPlanById(object payload, HttpContext httpContext); + Task GetFollowedMealPlans(object payload, HttpContext httpContext); + Task GetMyMealPlans(object payload, HttpContext httpContext); + Task GetCustomerWorkOutPlan(object payload, HttpContext httpContext); + Task GetCustomerWorkoutPlanById(object payload, HttpContext httpContext); + Task GetFollowedWorkoutPlans(object payload, HttpContext httpContext); + Task GetMyWorkoutPlans(object payload, HttpContext httpContext); + Task FollowMealPlan(object payload, HttpContext httpContext); + Task FollowWorkOutPlan(object payload, HttpContext httpContext); + Task SetWorkoutExerciseCompletion(Guid workoutPlanId, Guid exerciseId, bool completed, HttpContext httpContext); + Task GetClientDataById(object payload, HttpContext httpContext); + Task GetClientGyms(object payload, HttpContext httpContext); + // QR gym onboarding: list a gym's membership plans, then join by picking one. + Task GetGymPlans(object payload, HttpContext httpContext); + Task JoinGym(object payload, HttpContext httpContext); + // Gym-specific Terms & Conditions: view the gym's active terms and record acceptance. + Task GetGymTerms(object payload, HttpContext httpContext); + Task AcceptGymTerms(object payload, HttpContext httpContext); + Task GetGymTermsPdf(Guid clientId); + Task CreateCusHealthRec(object payload, HttpContext httpContext); + Task CreateCusProgressRec(object payload, HttpContext httpContext); + Task UpdateCusHealthRec(object payload, HttpContext httpContext); + Task UpdateCusProgressRec(object payload, HttpContext httpContext); + Task UpdateCustomerTargetNutritions(object payload, HttpContext httpContext); + Task GetCusBasicDetails(object payload, HttpContext httpContext); + Task GetCusHealthDetails(object payload, HttpContext httpContext); + Task GetCusProgressDetails(object payload, HttpContext httpContext); + Task GetCustomerProgress(object payload, HttpContext httpContext); + Task UpdateWeeklyProgress(object payload, HttpContext httpContext); + Task GetWeeklyProgressForCustomer(object payload, HttpContext httpContext); + Task CreateFeed(object payload, HttpContext httpContext); + Task GetCardDetails(object payload, HttpContext httpContext); + // Member self-serve auto-deduct: save a card with explicit consent, then opt a + // subscription in/out of being auto-charged against that card. + Task SaveCard(object payload, HttpContext httpContext); + Task SetAutoCharge(Guid subscriptionId, object payload, HttpContext httpContext); + // Lets the mobile app find the caller's active subscription id to target SetAutoCharge. + Task GetMySubscription(object payload, HttpContext httpContext); + Task GetMyGym(object payload, HttpContext httpContext); + Task UnfollowMyGym(object payload, HttpContext httpContext); + Task MyPayment(object payload, HttpContext httpContext); + // In-app Notification tab: history of assignment/feed pushes sent to this customer. + Task GetMyNotifications(object payload, HttpContext httpContext); + Task MarkNotificationRead(Guid notificationId, HttpContext httpContext); + // Refreshes the stored Expo push token (called at login, since a token can rotate). + Task UpdatePushToken(object payload, HttpContext httpContext); + // Links an already-uploaded document (POST /customer/documents) as this customer's + // profile picture. + Task UpdateProfileImage(object payload, HttpContext httpContext); + } +} diff --git a/Interfaces/Services/ICustomerSubscriptionService.cs b/Interfaces/Services/ICustomerSubscriptionService.cs new file mode 100644 index 0000000..0d5ce3f --- /dev/null +++ b/Interfaces/Services/ICustomerSubscriptionService.cs @@ -0,0 +1,22 @@ +using Fitness_core_BE.DTOs.CustomerSubscription; +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Interfaces.Services +{ + public interface ICustomerSubscriptionService + { + Task CreateCustomerSubscription(object payload, HttpContext httpContext); + Task GetCustomerSubscriptionById(object payload, HttpContext httpContext); + Task GetCustomerSubscriptionsByCustomerId(object payload, HttpContext httpContext); + Task GetActiveSubscriptionByCustomerId(object payload, HttpContext httpContext); + + Task GetSubscriptionSummary(object payload, HttpContext httpContext); + Task AddMemberToSubscription(object payload, HttpContext httpContext); + Task RemoveMemberFromSubscription(object payload, HttpContext httpContext); + Task> BuildSummariesForCustomerAsync(Guid customerId); + + // Optional extra fees on a subscription (req 6 — e.g. coaching fee, once or recurring). + Task> GetExtraFeesAsync(Guid subscriptionId, HttpContext httpContext); + Task AddExtraFeeAsync(Guid subscriptionId, AddExtraFeeRequest request, HttpContext httpContext); + } +} diff --git a/Interfaces/Services/IDocumentService.cs b/Interfaces/Services/IDocumentService.cs new file mode 100644 index 0000000..6522e5a --- /dev/null +++ b/Interfaces/Services/IDocumentService.cs @@ -0,0 +1,14 @@ +using Fitness_core_BE.DTOs; +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Interfaces.Services +{ + public interface IDocumentService + { + Task UploadDocument(object payload, HttpContext httpContext); + Task GetDocumentsByUserId(object payload, HttpContext httpContext); + Task GetDocumentById(object payload, HttpContext httpContext); + Task RemoveDocument(object payload, HttpContext httpContext); + Task GetFileBytesById(int documentId); + } +} diff --git a/Interfaces/Services/IFacilityService.cs b/Interfaces/Services/IFacilityService.cs new file mode 100644 index 0000000..00db98a --- /dev/null +++ b/Interfaces/Services/IFacilityService.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Interfaces.Services +{ + public interface IFacilityService + { + Task GetAllFacilities(object payload, HttpContext httpContext); + Task CreateFacility(object payload, HttpContext httpContext); + Task UpdateFacility(object payload, HttpContext httpContext); + Task SetFacilityStatus(object payload, HttpContext httpContext); + + Task CreateFacilitySession(object payload, HttpContext httpContext); + Task GetSessions(object payload, HttpContext httpContext); + Task CancelFacilitySession(object payload, HttpContext httpContext); + } +} diff --git a/Interfaces/Services/IFeedService.cs b/Interfaces/Services/IFeedService.cs new file mode 100644 index 0000000..e21ab3a --- /dev/null +++ b/Interfaces/Services/IFeedService.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Interfaces.Services +{ + public interface IFeedService + { + Task GetFeedPostById(object payload, HttpContext httpContext); + + Task GetFeedForCustomer(object payload, HttpContext httpContext); + Task LikeAsync(object payload, HttpContext httpContext); + Task IsLikedAsync(object payload, HttpContext httpContext); + Task UnlikeAsync(object payload, HttpContext httpContext); + Task CreateFeed(object payload, HttpContext httpContext); + Task ShowFeedByClient(object payload, HttpContext httpContext); + Task DeleteFeedByPostId(object payload, HttpContext httpContext); + } +} diff --git a/Interfaces/Services/IGymRoleService.cs b/Interfaces/Services/IGymRoleService.cs new file mode 100644 index 0000000..c356b72 --- /dev/null +++ b/Interfaces/Services/IGymRoleService.cs @@ -0,0 +1,19 @@ +using Fitness_core_BE.DTOs.Rbac; +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Interfaces.Services +{ + // Owner-only role CRUD + staff-role assignment. Every method resolves the caller via + // UserRoleHelper.GetClientId directly and never consults IPermissionService/GymRolePermission — + // this is what makes role management immune to staff self-escalation: no nav-section + // permission can ever unlock these methods for a staff account. + public interface IGymRoleService + { + Task> GetRolesAsync(HttpContext httpContext); + Task CreateRoleAsync(CreateGymRoleRequest request, HttpContext httpContext); + Task UpdateRoleAsync(Guid gymRoleId, UpdateGymRoleRequest request, HttpContext httpContext); + Task DeleteRoleAsync(Guid gymRoleId, HttpContext httpContext); + Task SetRolePermissionsAsync(Guid gymRoleId, SetRolePermissionsRequest request, HttpContext httpContext); + Task AssignStaffRoleAsync(Guid staffId, AssignStaffRoleRequest request, HttpContext httpContext); + } +} diff --git a/Interfaces/Services/IMealService.cs b/Interfaces/Services/IMealService.cs new file mode 100644 index 0000000..b5c815e --- /dev/null +++ b/Interfaces/Services/IMealService.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Interfaces.Services +{ + public interface IMealService + { + Task GetMealCategoryList(object payload, HttpContext httpContext); + Task CreateMealCategory(object payload, HttpContext httpContext); + Task GetMealPlanList(object payload, HttpContext httpContext); + Task GetMealPlanById(object payload, HttpContext httpContext); + Task GetMealPlanByCustomerId(object payload, HttpContext httpContext); + Task CreateMealPlan(object payload, HttpContext httpContext); + Task CreateCustomerMealPlan(object payload, HttpContext httpContext); + Task GetMealItemsByMealPlanId(object payload, HttpContext httpContext); + Task CreateMealItem(object payload, HttpContext httpContext); + Task UpdateMealPlan(object payload, HttpContext httpContext); + Task DeleteMealPlan(object payload, HttpContext httpContext); + Task AssignMealToCustomer(object payload, HttpContext httpContext); + Task UnassignMealFromCustomer(object payload, HttpContext httpContext); + } +} diff --git a/Interfaces/Services/INavigationService.cs b/Interfaces/Services/INavigationService.cs new file mode 100644 index 0000000..8f6d31f --- /dev/null +++ b/Interfaces/Services/INavigationService.cs @@ -0,0 +1,9 @@ +using Fitness_core_BE.DTOs.Rbac; + +namespace Fitness_core_BE.Interfaces.Services +{ + public interface INavigationService + { + Task GetNavigationAsync(HttpContext httpContext); + } +} diff --git a/Interfaces/Services/IPaymentGatewayClient.cs b/Interfaces/Services/IPaymentGatewayClient.cs new file mode 100644 index 0000000..9cdf49c --- /dev/null +++ b/Interfaces/Services/IPaymentGatewayClient.cs @@ -0,0 +1,26 @@ +using Fitness_core_BE.DTOs.PaymentGateways; + +namespace Fitness_core_BE.Interfaces.Services +{ + // Server-to-server client for Payment-Service's HMAC-signed, per-platform routes + // (Controllers/v1/CardsController.cs, PaymentsController.cs, RefundsController.cs there). + // Distinct from IPaymentServiceClient, which calls the X-Internal-Key admin/catalog surface. + // Every method here is scoped to one gym client and lazily provisions that client's + // Payment-Service Platform on first use (see PaymentGatewayClient.EnsureCredentialsAsync). + public interface IPaymentGatewayClient + { + Task OnboardCardAsync(Guid clientId, OnboardCardRequest request, CancellationToken ct = default); + Task GetCardDetailsAsync(Guid clientId, string cardToken, CancellationToken ct = default); + Task> ListCardsAsync(Guid clientId, string customerId, CancellationToken ct = default); + Task DeactivateCardAsync(Guid clientId, string cardToken, CancellationToken ct = default); + Task SetDefaultCardAsync(Guid clientId, string cardToken, string customerId, CancellationToken ct = default); + + /// Polls a hosted-page onboarding started earlier (CardToken is empty until + /// the gateway's webhook completes it — see Payment-Service's CardService). + Task GetOnboardingStatusAsync(Guid clientId, string gatewayName, string sessionId, CancellationToken ct = default); + + Task AttemptPaymentAsync(Guid clientId, AttemptPaymentRequest request, CancellationToken ct = default); + Task RefundPaymentAsync(Guid clientId, Guid paymentId, bool isVoid, CancellationToken ct = default); + Task ListPaymentsAsync(Guid clientId, string? cardToken, int page, int pageSize, CancellationToken ct = default); + } +} diff --git a/Interfaces/Services/IPaymentService.cs b/Interfaces/Services/IPaymentService.cs new file mode 100644 index 0000000..17f81ec --- /dev/null +++ b/Interfaces/Services/IPaymentService.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Interfaces.Services +{ + public interface IPaymentService + { + Task CreateCustomerPayment(object payload, HttpContext httpContext); + Task CreateClientPayment(object payload, HttpContext httpContext); + Task GetPaymentById(object payload, HttpContext httpContext); + Task GetPaymentsByCustomerInvoice(object payload, HttpContext httpContext); + Task GetPaymentsByClientInvoice(object payload, HttpContext httpContext); + Task ApprovePayment(object payload, HttpContext httpContext); + Task RecordSubscriptionPayment(object payload, HttpContext httpContext); + Task GetSubscriptionPayments(object payload, HttpContext httpContext); + + // "Charge now": owner-triggered attempt against the member's consented auto-charge card. + Task AttemptAutoCharge(Guid customerInvoiceId, HttpContext httpContext); + // Emails/texts the member a link to pay the invoice online themselves. + Task SendPaymentLink(Guid customerInvoiceId, HttpContext httpContext); + // Public pay-by-link surface (token proves authorization; no logged-in session). + Task GetInvoiceByPaymentToken(string token); + Task ChargeInvoiceByPaymentToken(string token, object payload); + } +} diff --git a/Interfaces/Services/IPaymentServiceClient.cs b/Interfaces/Services/IPaymentServiceClient.cs new file mode 100644 index 0000000..23aceca --- /dev/null +++ b/Interfaces/Services/IPaymentServiceClient.cs @@ -0,0 +1,19 @@ +using Fitness_core_BE.DTOs.PaymentGateways; + +namespace Fitness_core_BE.Interfaces.Services +{ + // Server-to-server client for Payment-Service's internal-key-protected admin + // routes (Controllers/v1/GatewaysController.cs, PlatformsController.cs there). + public interface IPaymentServiceClient + { + Task> ListGatewaysAsync(CancellationToken cancellationToken = default); + Task UpsertGatewayAsync(UpsertGatewayDefinitionRequest request, CancellationToken cancellationToken = default); + Task SetGatewayEnabledAsync(Guid id, bool isEnabled, CancellationToken cancellationToken = default); + Task UpsertPlatformCredentialAsync(UpsertPlatformGatewayCredentialRequest request, CancellationToken cancellationToken = default); + + // Mints (or returns) the Payment-Service Platform for a client; call once at + // client activation and store the returned PlatformId on Client.FKPaymentPlatformId. + Task<(Guid PlatformId, string ApiKey, string HmacSecret)> ProvisionPlatformAsync( + Guid clientId, string displayName, CancellationToken cancellationToken = default); + } +} diff --git a/Interfaces/Services/IPaymentsEnablementService.cs b/Interfaces/Services/IPaymentsEnablementService.cs new file mode 100644 index 0000000..cf6cf1e --- /dev/null +++ b/Interfaces/Services/IPaymentsEnablementService.cs @@ -0,0 +1,20 @@ +using Fitness_core_BE.DTOs.PaymentsEnablement; +using Fitness_core_BE.DTOs.PaymentGateways; +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Interfaces.Services +{ + public interface IPaymentsEnablementService + { + Task> GetTrackOptionsAsync(); + Task SelectTrackAsync(HttpContext httpContext, SelectPaymentTrackRequest request); + Task> GetAvailableGatewaysAsync(CancellationToken ct = default); + Task SubmitKycAsync( + HttpContext httpContext, + SubmitGatewayOnboardingRequest request, + CancellationToken ct = default); + Task GetKycStatusAsync(HttpContext httpContext); + Task GetWalletAsync(HttpContext httpContext); + Task GetPayoutScheduleAsync(HttpContext httpContext); + } +} diff --git a/Interfaces/Services/IPayoutKycReviewService.cs b/Interfaces/Services/IPayoutKycReviewService.cs new file mode 100644 index 0000000..09e05e0 --- /dev/null +++ b/Interfaces/Services/IPayoutKycReviewService.cs @@ -0,0 +1,12 @@ +using Fitness_core_BE.DTOs.PaymentsEnablement; +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Interfaces.Services +{ + public interface IPayoutKycReviewService + { + Task> ListAsync(string? status); + Task GetByClientIdAsync(Guid clientId); + Task ReviewAsync(Guid clientId, ReviewPayoutKycRequest request, HttpContext httpContext); + } +} diff --git a/Interfaces/Services/IPermissionService.cs b/Interfaces/Services/IPermissionService.cs new file mode 100644 index 0000000..f1cc77b --- /dev/null +++ b/Interfaces/Services/IPermissionService.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Interfaces.Services +{ + public record PermissionContext(bool IsOwner, Guid? ClientId, Guid? StaffId, IReadOnlySet AllowedNavKeys); + + public interface IPermissionService + { + // Single shared source of truth for "what can this caller see/do" — used by both + // RequiresPermissionAttribute and NavController so they can never drift apart. + Task GetPermissionContextAsync(HttpContext httpContext); + } +} diff --git a/Interfaces/Services/IPlaceService.cs b/Interfaces/Services/IPlaceService.cs new file mode 100644 index 0000000..ae8f234 --- /dev/null +++ b/Interfaces/Services/IPlaceService.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Interfaces.Services +{ + public interface IPlaceService + { + Task GetAllPlaces(object payload, HttpContext httpContext); + Task CreatePlace(object payload, HttpContext httpContext); + Task UpdatePlace(object payload, HttpContext httpContext); + Task SetPlaceStatus(object payload, HttpContext httpContext); + Task AssignDevice(object payload, HttpContext httpContext); + } +} diff --git a/Interfaces/Services/IPlatformBillingService.cs b/Interfaces/Services/IPlatformBillingService.cs new file mode 100644 index 0000000..8a419a1 --- /dev/null +++ b/Interfaces/Services/IPlatformBillingService.cs @@ -0,0 +1,28 @@ +using Fitness_core_BE.DTOs.PlatformBilling; +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces.Services +{ + // Computes and generates the platform's own bill to a gym ("Client"): a per-active-member + // SaaS fee (with minimum/cap), device rental, and one-time setup fees. Distinct from + // CustomerInvoice/CustomerSubscription, which is the gym billing its own members. + public interface IPlatformBillingService + { + Task> GetPricingPlansAsync(CancellationToken ct = default); + Task UpsertPricingPlanAsync(UpsertPricingPlanRequest request, CancellationToken ct = default); + Task GetDevicePricingAsync(CancellationToken ct = default); + Task UpdateDevicePricingAsync(DevicePricingDto request, CancellationToken ct = default); + Task AssignPricingPlanAsync(Guid clientId, AssignPricingPlanRequest request, CancellationToken ct = default); + + Task ComputeMonthlyFeeAsync(Guid clientId, CancellationToken ct = default); + Task GenerateMonthlyInvoiceAsync(Guid clientId, CancellationToken ct = default); + Task GenerateOnboardingInvoiceAsync(Guid clientId, CancellationToken ct = default); + Task> RunMonthlyBillingForAllClientsAsync(CancellationToken ct = default); + Task RunAndChargeSubscriptionBillingAsync(CancellationToken ct = default); + + Task> GetPendingInvoicesAsync(CancellationToken ct = default); + Task> GetCompletedInvoicesAsync(CancellationToken ct = default); + Task RecordClientInvoicePaymentAsync(Guid clientInvoiceId, decimal amount, CancellationToken ct = default); + Task SendClientPaymentLinkAsync(Guid clientInvoiceId, CancellationToken ct = default); + } +} diff --git a/Interfaces/Services/IProfitLossService.cs b/Interfaces/Services/IProfitLossService.cs new file mode 100644 index 0000000..eedafb1 --- /dev/null +++ b/Interfaces/Services/IProfitLossService.cs @@ -0,0 +1,16 @@ +using Fitness_core_BE.DTOs.Ledger; +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Interfaces.Services +{ + // Basic P&L / ledger for the client portal. Derives income (approved member payments) + // and expenses (platform fees paid, staff payroll) from existing tables, and merges in + // manual ledger entries (LedgerEntry) for anything else (rent, utilities, misc income). + public interface IProfitLossService + { + Task GetProfitLossAsync(DateTime from, DateTime to, HttpContext httpContext); + Task> GetManualEntriesAsync(DateTime from, DateTime to, HttpContext httpContext); + Task AddManualEntryAsync(AddLedgerEntryRequest request, HttpContext httpContext); + Task DeleteManualEntryAsync(Guid id, HttpContext httpContext); + } +} diff --git a/Interfaces/Services/IPushNotificationService.cs b/Interfaces/Services/IPushNotificationService.cs new file mode 100644 index 0000000..511cd22 --- /dev/null +++ b/Interfaces/Services/IPushNotificationService.cs @@ -0,0 +1,25 @@ +namespace Fitness_core_BE.Interfaces.Services +{ + public interface IPushNotificationService + { + // Persists a CustomerNotification row (for the mobile Notification tab) and, if the + // customer has a registered Expo push token, sends a real push too. routeName/routeParams + // mirror the mobile app's PushNavigationData contract (pushNotification.ts) so tapping + // either the push or the in-app list item navigates the same way. + Task NotifyCustomerAsync( + Guid customerId, + string title, + string body, + string notificationType, + string? routeName = null, + object? routeParams = null); + + Task NotifyCustomersAsync( + IEnumerable customerIds, + string title, + string body, + string notificationType, + string? routeName = null, + object? routeParams = null); + } +} diff --git a/Interfaces/Services/IReceiptService.cs b/Interfaces/Services/IReceiptService.cs new file mode 100644 index 0000000..688d0da --- /dev/null +++ b/Interfaces/Services/IReceiptService.cs @@ -0,0 +1,20 @@ +using Fitness_core_BE.Models.Receipt; + +namespace Fitness_core_BE.Interfaces.Services +{ + public interface IReceiptService + { + /// Gathers member/plan/gym/amount data for a member invoice. + /// When is set, throws if the invoice isn't that gym's. + Task BuildForInvoiceAsync(Guid customerInvoiceId, decimal? paymentAmount = null, Guid? requireClientId = null); + + /// Gathers gym/amount data for a platform (HexaFitness -> gym) invoice. + /// When is set, throws if the invoice isn't that gym's. + Task BuildForClientInvoiceAsync(Guid clientInvoiceId, decimal? paymentAmount = null, Guid? requireClientId = null); + + byte[] GeneratePdf(ReceiptModel model); + + /// Best-effort: emails the receipt PDF and texts the member. Never throws. + Task SendReceiptAsync(ReceiptModel model); + } +} diff --git a/Interfaces/Services/IStaffService.cs b/Interfaces/Services/IStaffService.cs new file mode 100644 index 0000000..a855842 --- /dev/null +++ b/Interfaces/Services/IStaffService.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Interfaces.Services +{ + public interface IStaffService + { + Task GetAllStaff(object payload, HttpContext httpContext); + Task GetStaffById(object payload, HttpContext httpContext); + Task GetStaffByClientId(object payload, HttpContext httpContext); + Task CreateStaff(object payload, HttpContext httpContext); + Task UpdateStaff(object payload, HttpContext httpContext); + Task DeleteStaff(object payload, HttpContext httpContext); + + // Basic HRM/payroll (req 5). + Task GetPayRate(Guid staffId, HttpContext httpContext); + Task UpsertPayRate(Guid staffId, string rateType, decimal amount, HttpContext httpContext); + Task GetPayments(Guid staffId, HttpContext httpContext); + Task RecordPayment(Guid staffId, decimal amount, string? note, HttpContext httpContext); + } +} diff --git a/Interfaces/Services/ISubscriptionRenewalApprovalService.cs b/Interfaces/Services/ISubscriptionRenewalApprovalService.cs new file mode 100644 index 0000000..9ffe461 --- /dev/null +++ b/Interfaces/Services/ISubscriptionRenewalApprovalService.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Interfaces.Services +{ + // Staff-facing approval workflow for the unapproved (IsActive=false) invoices the daily + // SubscriptionRenewalJob sweep creates. See Services/SubscriptionRenewalApprovalService.cs. + public interface ISubscriptionRenewalApprovalService + { + Task GetUnapprovedInvoicesAsync(HttpContext httpContext); + Task ApproveAsync(Guid customerInvoiceId, HttpContext httpContext); + Task ChangePlanAsync(Guid customerInvoiceId, Guid newCustomerPlanId, HttpContext httpContext); + Task RejectAsync(Guid customerInvoiceId, HttpContext httpContext); + } +} diff --git a/Interfaces/Services/IThirdPartyService.cs b/Interfaces/Services/IThirdPartyService.cs new file mode 100644 index 0000000..bc15e2d --- /dev/null +++ b/Interfaces/Services/IThirdPartyService.cs @@ -0,0 +1,11 @@ +using Fitness_core_BE.Models.IOs; + +namespace Fitness_core_BE.Interfaces.Services +{ + /// Outbound notification channels (SMS + email) — mirrors Core-Hex-Auth's ThirdPartyService. + public interface IThirdPartyService + { + Task SendSmsAsync(string mobileNumber, string? otpCode = null, string? message = null); + Task SendEmailAsync(string toEmail, string subject, string htmlBody, byte[]? attachment = null, string? attachmentName = null); + } +} diff --git a/Interfaces/Services/ITicketService.cs b/Interfaces/Services/ITicketService.cs new file mode 100644 index 0000000..4ef2cb2 --- /dev/null +++ b/Interfaces/Services/ITicketService.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Interfaces.Services +{ + public interface ITicketService + { + Task GetTicketList(object payload, HttpContext httpContext); + Task GetTicketByUserId(object payload, HttpContext httpContext); + Task GetTicketById(object payload, HttpContext httpContext); + Task GetTicketCategoryList(object payload, HttpContext httpContext); + Task CreateTicket(object payload, HttpContext httpContext); + Task UpdateTicket(object payload, HttpContext httpContext); + Task SendMessage(object payload, HttpContext httpContext); + Task SubmitTicketFeedback(object payload, HttpContext httpContext); + Task CloseTicket(object payload, HttpContext httpContext); + Task DeleteTicket(object payload, HttpContext httpContext); + } +} diff --git a/Interfaces/Services/ITimeBankCardService.cs b/Interfaces/Services/ITimeBankCardService.cs new file mode 100644 index 0000000..cb3d336 --- /dev/null +++ b/Interfaces/Services/ITimeBankCardService.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Interfaces.Services +{ + public interface ITimeBankCardService + { + /// Staff issues/sells a card -- minutes purchased, optionally linked to a member. + Task IssueCard(object payload, HttpContext httpContext); + + /// The tap-in/tap-out swipe action: first swipe on a card starts its session + /// (no deduction), second swipe ends it and deducts the elapsed minutes. No login + /// required (called by the reader/agent, or by staff at a manual terminal). + Task Swipe(object payload); + + Task GetBalance(object payload); + Task GetMyCards(object payload, HttpContext httpContext); + } +} diff --git a/Interfaces/Services/IWorkoutService.cs b/Interfaces/Services/IWorkoutService.cs new file mode 100644 index 0000000..7f95e8a --- /dev/null +++ b/Interfaces/Services/IWorkoutService.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Interfaces.Services +{ + public interface IWorkoutService + { + Task GetWorkoutCategoryList(object payload, HttpContext httpContext); + Task CreateWorkoutCategory(object payload, HttpContext httpContext); + Task GetWorkoutPlanList(object payload, HttpContext httpContext); + Task GetWorkoutPlanById(object payload, HttpContext httpContext); + Task GetWorkoutPlanByCustomerId(object payload, HttpContext httpContext); + Task CreateWorkoutPlan(object payload, HttpContext httpContext); + Task CreateCustomerWorkout(object payload, HttpContext httpContext); + Task UpdateWorkoutPlan(object payload, HttpContext httpContext); + Task DeleteWorkoutPlan(object payload, HttpContext httpContext); + Task GetWorkoutItemsByWorkoutPlanId(object payload, HttpContext httpContext); + Task CreateWorkoutItem(object payload, HttpContext httpContext); + Task AssignWorkoutToCustomer(object payload, HttpContext httpContext); + Task UnassignWorkoutFromCustomer(object payload, HttpContext httpContext); + } +} diff --git a/Models/AIAgents.cs b/Models/AIAgents.cs new file mode 100644 index 0000000..92e7fc6 --- /dev/null +++ b/Models/AIAgents.cs @@ -0,0 +1,9 @@ +namespace Fitness_core_BE.Models +{ + public static class AIAgents + { + public static readonly Guid MealAgentId = Guid.Parse("00000000-0000-0000-0000-000000000101"); + + public static readonly Guid WorkoutAgentId = Guid.Parse("00000000-0000-0000-0000-000000000102"); + } +} diff --git a/Models/AccessDevice.cs b/Models/AccessDevice.cs new file mode 100644 index 0000000..c5f3d3a --- /dev/null +++ b/Models/AccessDevice.cs @@ -0,0 +1,65 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Security.Cryptography; +using System.Text; + +namespace Fitness_core_BE.Models +{ + public class AccessDevice + { + [Key] + public Guid DeviceId { get; set; } + public string DeviceName { get; set; } + + // DEPRECATED: connection details now live on the on-prem agent (Fitness_core_BE.Models.Agent), + // not in the cloud. Kept nullable so existing rows survive; no new code should read/write these. + [Obsolete("Device connection details now live on the on-prem agent, not in the cloud.")] + public string? PublicIp { get; set; } + [Obsolete("Device connection details now live on the on-prem agent, not in the cloud.")] + public string? Port { get; set; } + [Obsolete("Device connection details now live on the on-prem agent, not in the cloud.")] + public string? UserName { get; set; } + [Obsolete("Device connection details now live on the on-prem agent, not in the cloud.")] + public string? Password { get; set; } + + public Guid FKClientId { get; set; } + [ForeignKey("FKClientId")] + public Client Client { get; set; } + + // The agent that currently owns this LAN device. Commands are dispatched only + // to this agent, which prevents a second agent for the same gym receiving them. + public Guid? FKAgentId { get; set; } + [ForeignKey(nameof(FKAgentId))] + public Agent? Agent { get; set; } + + // Stable serial/device identifier reported by ISAPI. Used to re-claim the same + // cloud row after reinstalling the desktop agent instead of creating duplicates. + [MaxLength(160)] + public string? HardwareId { get; set; } + + // Which Place (physical zone) this terminal sits in. Null = unscoped: reachable by + // every plan regardless of that plan's Places (keeps pre-existing devices and + // plans without any place restriction working unchanged). Unique when set -- + // exactly one device per place for now (see Models/Place.cs). + public Guid? FKPlaceId { get; set; } + [ForeignKey(nameof(FKPlaceId))] + public Place? Place { get; set; } + + public bool IsActive { get; set; } = true; + public DateTime? LastSeenAt { get; set; } + // Explicit reachability reported by the on-prem agent. LastSeenAt remains + // the last successful terminal operation; health reports are tracked + // separately so status does not depend on browser polling. + public bool IsReachable { get; set; } + public DateTime? LastHealthReportedAt { get; set; } + public DateTime CreatedAt { get; set; }= DateTime.UtcNow; + + [Obsolete("Device connection details now live on the on-prem agent, not in the cloud.")] + public void SetPassword(string plainText) + => Password = EncryptionHelper.Encrypt(plainText); + + [Obsolete("Device connection details now live on the on-prem agent, not in the cloud.")] + public string GetPlainTextPassword() + => EncryptionHelper.Decrypt(Password!); + } +} diff --git a/Models/AccessDeviceBinding.cs b/Models/AccessDeviceBinding.cs new file mode 100644 index 0000000..bb0f9c7 --- /dev/null +++ b/Models/AccessDeviceBinding.cs @@ -0,0 +1,44 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + /// + /// Maps one tenant access profile to one physical terminal user record. + /// DeviceEmployeeNo is the terminal's person identifier, not a fingerprint template. + /// + public class AccessDeviceBinding + { + [Key] + public Guid AccessDeviceBindingId { get; set; } + + public long FKAccessProfileId { get; set; } + [ForeignKey(nameof(FKAccessProfileId))] + public AccessProfile AccessProfile { get; set; } = null!; + + public Guid FKDeviceId { get; set; } + [ForeignKey(nameof(FKDeviceId))] + public AccessDevice AccessDevice { get; set; } = null!; + + [MaxLength(32)] + public string DeviceEmployeeNo { get; set; } = string.Empty; + + [MaxLength(20)] + public string ProvisionStatus { get; set; } = "Pending"; + + [MaxLength(20)] + public string ActualAccessState { get; set; } = "Unknown"; + + public int FingerprintCount { get; set; } + + [MaxLength(128)] + public string? DeviceReportedName { get; set; } + + [MaxLength(1000)] + public string? LastError { get; set; } + + public DateTime? LastSyncedAt { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/AccessProfile.cs b/Models/AccessProfile.cs new file mode 100644 index 0000000..005bed9 --- /dev/null +++ b/Models/AccessProfile.cs @@ -0,0 +1,67 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + /// + /// Tenant-scoped physical-access identity. A platform user can belong to several + /// gyms, but receives one independent access profile per gym. + /// + public class AccessProfile + { + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public long AccessProfileId { get; set; } + + public Guid FKClientId { get; set; } + [ForeignKey(nameof(FKClientId))] + public Client Client { get; set; } = null!; + + public Guid FKUserId { get; set; } + [ForeignKey(nameof(FKUserId))] + public User User { get; set; } = null!; + + [MaxLength(20)] + public string PersonType { get; set; } = "Customer"; + + [MaxLength(128)] + public string DisplayName { get; set; } = string.Empty; + + [MaxLength(20)] + public string DesiredAccessState { get; set; } = "Enabled"; + + public int DesiredVersion { get; set; } = 1; + + [MaxLength(100)] + public string? LockReason { get; set; } + + public DateTime? LockedAt { get; set; } + + // ---- Face photo (Hikvision face library) ---- + // The image itself is never persisted here: it travels in the UpsertFacePhoto + // DeviceCommand payload only until the terminal confirms enrolment, at which point + // the payload is purged. What survives is the enrolment state plus the device-side + // record id, which is what a later replace/delete needs. + + /// True once the terminal has accepted a face for this profile. + public bool HasFacePhoto { get; set; } + + /// "None" | "Pending" | "Synced" | "Failed". + [MaxLength(20)] + public string FacePhotoStatus { get; set; } = "None"; + + /// Hikvision face-record id (FPID) returned on enrolment. + [MaxLength(64)] + public string? FacePhotoFPID { get; set; } + + /// Why the terminal rejected the face (not frontal, eyes closed, ...). + [MaxLength(500)] + public string? FacePhotoError { get; set; } + + public DateTime? FacePhotoUpdatedAt { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + public ICollection DeviceBindings { get; set; } = new List(); + } +} diff --git a/Models/ActivityLog.cs b/Models/ActivityLog.cs new file mode 100644 index 0000000..8980150 --- /dev/null +++ b/Models/ActivityLog.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class ActivityLog + { + [Key] + public Guid ActivityLogId { get; set; } + public Guid FKUserId { get; set; } + [ForeignKey("FKUserId")] + public virtual User User { get; set; } + public string UserName { get; set; } + public bool IsAdmin { get; set; } + public string Description { get; set; } + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/Admin.cs b/Models/Admin.cs new file mode 100644 index 0000000..7de9302 --- /dev/null +++ b/Models/Admin.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class Admin + { + [Key] + public Guid AdminId { get; set; } + public string AdminName { get; set; } + public Guid FKUserId { get; set; } + [ForeignKey("FKUserId")] + public virtual User User { get; set; } + public string PhoneNumber { get; set; } + public bool IsActive { get; set; } = true; + public DateTime CreateDate { get; set; } = DateTime.UtcNow; + public virtual ICollection AssignedTickets { get; set; } + } +} diff --git a/Models/Agent.cs b/Models/Agent.cs new file mode 100644 index 0000000..f4507a9 --- /dev/null +++ b/Models/Agent.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class Agent + { + [Key] + public Guid AgentId { get; set; } + public Guid FKClientId { get; set; } + [ForeignKey("FKClientId")] + public Client Client { get; set; } + public string Name { get; set; } + public string ApiKeyHash { get; set; } + public bool IsActive { get; set; } = true; + public DateTime? LastSeenAt { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/Attendance.cs b/Models/Attendance.cs new file mode 100644 index 0000000..591b650 --- /dev/null +++ b/Models/Attendance.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class Attendance + { + [Key] + public Guid AttendanceId { get; set; } + public Guid FKClientId { get; set; } + [ForeignKey("FKClientId")] + public Client Client { get; set; } + + public Guid? FKDeviceId { get; set; } + [ForeignKey(nameof(FKDeviceId))] + public AccessDevice? AccessDevice { get; set; } + + // Tenant-bucket entity: this FK holds a TenantCustomerLinkId (not the central + // Customer.CusId directly) -- see Models/TenantCustomerLink.cs. + public Guid? FKCustomerId { get; set; } + [ForeignKey("FKCustomerId")] + public TenantCustomerLink? TenantCustomerLink { get; set; } + + public Guid? FKUserId { get; set; } + [ForeignKey(nameof(FKUserId))] + public User? User { get; set; } + + public long? FKAccessProfileId { get; set; } + [ForeignKey(nameof(FKAccessProfileId))] + public AccessProfile? AccessProfile { get; set; } + + public int DeviceUserId { get; set; } + [MaxLength(32)] + public string DeviceEmployeeNo { get; set; } = string.Empty; + [MaxLength(64)] + public string? SourceEventKey { get; set; } + public DateTime EventTime { get; set; } + public string EventType { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/Booking.cs b/Models/Booking.cs new file mode 100644 index 0000000..f7cf62b --- /dev/null +++ b/Models/Booking.cs @@ -0,0 +1,68 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + /// One reservation against a FacilitySession — either an existing member using a + /// CustomerPlan session allowance (FKCustomerId set, IsGuest = false) or a non-member who + /// booked online and pays per-session (IsGuest = true, no membership at all). The guest's + /// identity proof for check-in is the OTP issued at booking time, verified at the door + /// instead of a fingerprint/face profile a non-member will never have. + public class Booking + { + [Key] + public Guid BookingId { get; set; } + + public Guid FKFacilitySessionId { get; set; } + [ForeignKey(nameof(FKFacilitySessionId))] + public FacilitySession FacilitySession { get; set; } + + public Guid FKClientId { get; set; } + [ForeignKey(nameof(FKClientId))] + public Client Client { get; set; } + + public bool IsGuest { get; set; } = false; + + // Member path. Tenant-bucket entity: holds a TenantCustomerLinkId, not the central + // Customer.CusId directly -- see Models/TenantCustomerLink.cs. + public Guid? FKCustomerId { get; set; } + [ForeignKey(nameof(FKCustomerId))] + public TenantCustomerLink? TenantCustomerLink { get; set; } + + // A member's booking draws down this specific subscription's per-cycle session + // allowance; null for guest bookings (they have no subscription at all). + public Guid? FKCustomerSubscriptionId { get; set; } + [ForeignKey(nameof(FKCustomerSubscriptionId))] + public CustomerSubscription? CustomerSubscription { get; set; } + + // Guest path -- no member record exists at all, so identity is captured inline. + [MaxLength(200)] + public string? GuestName { get; set; } + [MaxLength(32)] + public string? GuestPhone { get; set; } + + /// Issued when the booking is created; the guest presents this at the door + /// instead of a fingerprint/face profile. Members don't need one -- their existing + /// access credential covers check-in. + [MaxLength(10)] + public string? OtpCode { get; set; } + public DateTime? OtpVerifiedAt { get; set; } + + /// "Paid" | "Unpaid" | "NotRequired" (member using a plan's included session). + public string PaymentStatus { get; set; } = "NotRequired"; + public decimal AmountCharged { get; set; } = 0m; + + // Set only once payment is confirmed for a pay-per-session guest booking, or + // immediately for a member session (no payment gate) -- see PaymentStatus. + public Guid? FKPaymentTransactionId { get; set; } + [ForeignKey(nameof(FKPaymentTransactionId))] + public PaymentTransaction? PaymentTransaction { get; set; } + + /// "Confirmed" | "CheckedIn" | "Cancelled" | "NoShow". + public string Status { get; set; } = "Confirmed"; + + public DateTime BookedAt { get; set; } = DateTime.UtcNow; + public DateTime? CheckedInAt { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/Chat.cs b/Models/Chat.cs new file mode 100644 index 0000000..fbb0913 --- /dev/null +++ b/Models/Chat.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class Chat + { + public Guid Id { get; set; } + public int UserId { get; set; } + public Guid FKSenderId { get; set; } + [ForeignKey("FKSenderId")] + public Guid FKReceiverId { get; set; } + [ForeignKey("FKReceiverId")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public bool IsAcitve { get; set; } = true; + } +} diff --git a/Models/ChatMessages.cs b/Models/ChatMessages.cs new file mode 100644 index 0000000..128cf15 --- /dev/null +++ b/Models/ChatMessages.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class ChatMessages + { + public Guid Id { get; set; } + public int UserId { get; set; } + public string Message { get; set; } + public Guid FKSenderId { get; set; } + [ForeignKey("FKSenderId")] + public User User { get; set; } + public string Type { get; set; } = "userMessages"; + public Guid FKChatId { get; set; } + [ForeignKey("FKChatId")] + public Chat Chat { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public bool IsAcitve { get; set; } = true; + } +} diff --git a/Models/Client.cs b/Models/Client.cs new file mode 100644 index 0000000..cce5523 --- /dev/null +++ b/Models/Client.cs @@ -0,0 +1,46 @@ +using Fitness_core_BE.Models; +using Fitness_core_BE.Models.Enums; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Collections.Generic; + +namespace Fitness_core_BE.Models +{ + public class Client +{ + [Key] + public Guid ClientId { get; set; } + public string? ClientName { get; set; } + public Guid FKUserId { get; set; } + [ForeignKey("FKUserId")] + public virtual User User { get; set; } + + public string? ClientPhone { get; set; } + + public string? ClientEmail { get; set; } + + public PaymentTrack? PaymentTrack { get; set; } + + // Payment-Service's Platform.Id for this client, minted the first time this + // client needs to take a gateway-routed payment (see PaymentServiceClient). + public Guid? FKPaymentPlatformId { get; set; } + + // Payment-Service ApiKey/HmacSecret returned alongside FKPaymentPlatformId when the + // platform was minted. Needed to sign card/payment/refund calls (RequireHmacValidation + // on Payment-Service's side) — provisioning only ever returns these once. + public string? ClientPaymentApiKey { get; set; } + public string? ClientPaymentHmacSecret { get; set; } + + // Days a member can go without payment before OverdueLockJob auto-locks their + // fingerprint access. Client-configurable (see req 7); null defaults to + // DefaultLockAfterDaysOverdue; an explicit 0 disables auto-lock for this client. + public int? LockAfterDaysOverdue { get; set; } + public const int DefaultLockAfterDaysOverdue = 2; + + public virtual ICollection ClientBusinesses { get; set; } = new List(); + + + + + } +} diff --git a/Models/ClientBusiness.cs b/Models/ClientBusiness.cs new file mode 100644 index 0000000..606adb8 --- /dev/null +++ b/Models/ClientBusiness.cs @@ -0,0 +1,53 @@ +using Fitness_core_BE.Models.Enums; +using System.ComponentModel.DataAnnotations.Schema; +using System.ComponentModel.DataAnnotations; + +namespace Fitness_core_BE.Models +{ + public class ClientBusiness +{ + [Key] + public Guid Bid { get; set; } + + public Guid? FKClientId { get; set; } + [ForeignKey("FKClientId")] + public virtual Client Client { get; set; } + + public BusinessType BusinessType { get; set; } + + public string? BusinessName { get; set; } + + public string? BusinessLogoId { get; set; } + public string? Description { get; set; } + + // Pysical Location + public bool IsPysicalLocation { get; set; } = true; + public string? Address { get; set; } + public string? City { get; set; } + public string? Facilities { get; set; } + + //Coach + public string? CoachingMode { get; set; } + public string? Expertise { get; set; } + public string? Gender { get; set; } + + public string? ExperienceYears { get; set; } + public string? SessionType { get; set; } + + + //Product Store + public string? StoreType { get; set; } + public string? ProductCategory { get; set; } + public string? DeliveryAvailable { get; set; } + + //Geo Location + public double? Latitude { get; set; } + public double? Longitude { get; set; } + + + + + public DateTime createdAt { get; set; } = DateTime.UtcNow; + public bool IsActive { get; set; } = true; + } +} diff --git a/Models/ClientCard.cs b/Models/ClientCard.cs new file mode 100644 index 0000000..9b7117f --- /dev/null +++ b/Models/ClientCard.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Models +{ + public class ClientCard + { + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int CardId { get; set; } + + public int JobId { get; set; } + public Guid FK_ClientId { get; set; } + [ForeignKey("FK_ClientId")] + public virtual Client Client { get; set; } + + public string? WalletId { get; set; } + public string? CardNumber { get; set; } + public string? CardType { get; set; } + public string? CardDate { get; set; } + public bool IsActive { get; set; } = false; + public bool IsPrimary { get; set; } = false; + + // Payment-Service's own card identifier — the one thing we keep locally beyond + // ApiKey/HmacSecret, because ClientInvoice.FKClientCardId needs a stable local row to + // point at. Everything else about the card (masked PAN, brand, expiry, live status) + // is fetched from Payment-Service on demand, never cached here. + public string? CardToken { get; set; } + } +} diff --git a/Models/ClientDeviceBilling.cs b/Models/ClientDeviceBilling.cs new file mode 100644 index 0000000..a35b217 --- /dev/null +++ b/Models/ClientDeviceBilling.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + // Records what an admin actually agreed with a client for their fingerprint/access + // device at assignment time (full purchase vs. rental + one-time setup fee). + // DevicePricingConfig stays as the admin's reference/default rates; this is per-client. + public class ClientDeviceBilling + { + [Key] + public Guid Id { get; set; } + + public Guid FKClientId { get; set; } + [ForeignKey("FKClientId")] + public virtual Client Client { get; set; } + + public string Mode { get; set; } = "Rental"; // "FullPurchase" | "Rental" + public decimal AgreedAmount { get; set; } // full price if FullPurchase, monthly fee if Rental + public decimal SetupFee { get; set; } = 0; + + public bool IsActive { get; set; } = true; + public DateTime AssignedAt { get; set; } = DateTime.UtcNow; + public Guid? AssignedByAdminId { get; set; } + } +} diff --git a/Models/ClientGymResponse.cs b/Models/ClientGymResponse.cs new file mode 100644 index 0000000..3c497f6 --- /dev/null +++ b/Models/ClientGymResponse.cs @@ -0,0 +1,11 @@ +namespace Fitness_core_BE.Models.IOs +{ + public class ClientGymResponse + { + public Guid Id { get; set; } + public string? Name { get; set; } + public double? Latitude { get; set; } + public double? Longitude { get; set; } + public bool IsActive { get; set; } + } +} diff --git a/Models/ClientInvoice.cs b/Models/ClientInvoice.cs new file mode 100644 index 0000000..be857ba --- /dev/null +++ b/Models/ClientInvoice.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class ClientInvoice + { + [Key] + public Guid ClientInvoiceId { get; set; } + public Guid FKClientSubscriptionId { get; set; } + [ForeignKey("FKClientSubscriptionId")] + public virtual ClientSubscription ClientSubscription { get; set; } + + public Guid FKUserId { get; set; } + [ForeignKey("FKUserId")] + public virtual User User { get; set; } + + public int FKClientCardId { get; set; } + [ForeignKey("FKClientCardId")] + public virtual ClientCard ClientCard { get; set; } + + public decimal Amount { get; set; } + // "Pending" | "Partial" | "Paid" + public string Status { get; set; } = "Pending"; + public decimal PaidAmount { get; set; } = 0; + + public DateTime ClientInvoiceDate { get; set; } = DateTime.UtcNow; + + public virtual ICollection Items { get; set; } = new List(); + } +} diff --git a/Models/ClientInvoiceItem.cs b/Models/ClientInvoiceItem.cs new file mode 100644 index 0000000..4f70628 --- /dev/null +++ b/Models/ClientInvoiceItem.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class ClientInvoiceItem + { + [Key] + public Guid ClientInvoiceItemId { get; set; } + public Guid FKClientInvoiceId { get; set; } + [ForeignKey("FKClientInvoiceId")] + public virtual ClientInvoice ClientInvoice { get; set; } + public string? ItemName { get; set; } + public string? ItemDescription { get; set; } + public decimal ItemPrice { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/ClientPackage.cs b/Models/ClientPackage.cs new file mode 100644 index 0000000..9337180 --- /dev/null +++ b/Models/ClientPackage.cs @@ -0,0 +1,46 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fitness_core_BE.Models +{ + public class ClientPackage +{ + + [Key] + public Guid ClientPackageId { get; set; } + public string? PackageName { get; set; } + public string? PackageDescription { get; set; } + public decimal PackagePrice { get; set; } + + // Platform (HexaFitness -> gym) billing formula for this plan. + // baseFee = MinimumMonthlyBill + max(0, min(activeMembers, MaxBillableMemberCount) - IncludedMemberCount) * PerMemberRate, + // capped by MonthlyCap when set. PerMemberRate = 0 preserves legacy flat-PackagePrice behavior for existing rows. + public decimal PerMemberRate { get; set; } = 0; + public decimal MinimumMonthlyBill { get; set; } = 0; + public decimal? MonthlyCap { get; set; } + public decimal SetupFee { get; set; } = 0; + + // Members already covered by MinimumMonthlyBill before PerMemberRate overage kicks in. + public int IncludedMemberCount { get; set; } = 0; + // Optional ceiling on the member count billed for (null = unlimited). + public int? MaxBillableMemberCount { get; set; } + + // Platform's cut of a client's own member-billing, depending on which gateway + // (the client's own, or HexaFitness's) their member payments route through. + public decimal OwnGatewayFeePercent { get; set; } = 0; + public decimal OwnGatewayFeeFixed { get; set; } = 0; + public decimal OurGatewayFeePercent { get; set; } = 0; + public decimal OurGatewayFeeFixed { get; set; } = 0; + + // Day of month (1-28) the client is paid out on, for clients on their own gateway. + public int PayoutDayOfMonth { get; set; } = 1; + + // The plan applied to a client when it has no active ClientSubscription assignment. + // Only one row should have IsDefault = true. + public bool IsDefault { get; set; } = false; + + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + + } +} diff --git a/Models/ClientPayoutKyc.cs b/Models/ClientPayoutKyc.cs new file mode 100644 index 0000000..cdec88b --- /dev/null +++ b/Models/ClientPayoutKyc.cs @@ -0,0 +1,48 @@ +using Fitness_core_BE.Models.Enums; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class ClientPayoutKyc + { + [Key] + public Guid Id { get; set; } + + public Guid FKClientId { get; set; } + [ForeignKey("FKClientId")] + public virtual Client Client { get; set; } + + public int? BrCertificateDocId { get; set; } + [ForeignKey("BrCertificateDocId")] + public virtual UserDocument? BrCertificateDoc { get; set; } + + public int? BankProofDocId { get; set; } + [ForeignKey("BankProofDocId")] + public virtual UserDocument? BankProofDoc { get; set; } + + // Legacy payout-KYC uploads. New gateway onboarding reuses the NIC front + // and back collected during owner activation instead of uploading it again. + public int? NicDocId { get; set; } + [ForeignKey("NicDocId")] + public virtual UserDocument? NicDoc { get; set; } + + // GatewayDefinition lives in Payment-Service, so this is an external + // catalog identifier rather than an EF foreign key. + public Guid? SelectedGatewayId { get; set; } + + [MaxLength(100)] + public string? SelectedGatewayName { get; set; } + + public KycStatus KycStatus { get; set; } = KycStatus.Pending; + public DateTime SubmittedAt { get; set; } = DateTime.UtcNow; + public DateTime? ReviewedAt { get; set; } + + [MaxLength(500)] + public string? RejectionReason { get; set; } + + public Guid? ReviewedByAdminId { get; set; } + [ForeignKey("ReviewedByAdminId")] + public virtual Admin? ReviewedByAdmin { get; set; } + } +} diff --git a/Models/ClientSubItems.cs b/Models/ClientSubItems.cs new file mode 100644 index 0000000..87e9848 --- /dev/null +++ b/Models/ClientSubItems.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class ClientSubItems +{ + + [Key] + public Guid ClientSubItemsId { get; set; } + public Guid FKClientSubscriptionId { get; set; } + [ForeignKey("FKClientSubscriptionId")] + public virtual ClientSubscription ClientSubscription { get; set; } + public string? SubItemName { get; set; } + public string? SubItemDescription { get; set; } + public decimal SubItemPrice { get; set; } + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/ClientSubscription.cs b/Models/ClientSubscription.cs new file mode 100644 index 0000000..97b89ed --- /dev/null +++ b/Models/ClientSubscription.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class ClientSubscription +{ + [Key] + public Guid SubscriptionId { get; set;} + + public Guid FKClientPackageId { get; set; } + [ForeignKey("FKClientPackageId")] + public virtual ClientPackage ClientPackage { get; set; } + + public Guid FKClientId { get; set; } + [ForeignKey("FKClientId")] + public virtual Client Client { get; set; } + public DateTime OnboardDate { get; set; } = DateTime.UtcNow; + + public DateTime? ExpiryDate { get; set; } + + public bool IsActive { get; set; } = true; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + + + } +} diff --git a/Models/ClientTerms.cs b/Models/ClientTerms.cs new file mode 100644 index 0000000..adafcad --- /dev/null +++ b/Models/ClientTerms.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + /// A gym's own Terms & Conditions, authored in the client portal. Only one row per + /// (ClientId, IsActive=true) at a time — publishing a new version deactivates the old one so + /// CustomerTermsAcceptance rows keep pointing at the exact version they accepted. + public class ClientTerms + { + [Key] + public Guid ClientTermsId { get; set; } + + public Guid FKClientId { get; set; } + [ForeignKey("FKClientId")] + public virtual Client? Client { get; set; } + + public int Version { get; set; } = 1; + public string Content { get; set; } = string.Empty; + + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/ClientWallet.cs b/Models/ClientWallet.cs new file mode 100644 index 0000000..320dbf3 --- /dev/null +++ b/Models/ClientWallet.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class ClientWallet + { + [Key] + public Guid Id { get; set; } + + public Guid FKClientId { get; set; } + [ForeignKey("FKClientId")] + public virtual Client Client { get; set; } + + public decimal Balance { get; set; } = 0; + public string Currency { get; set; } = "LKR"; + public DateTime? LastPayoutDate { get; set; } + + public virtual ICollection LedgerEntries { get; set; } = new List(); + } +} diff --git a/Models/CusWeightLossProgress.cs b/Models/CusWeightLossProgress.cs new file mode 100644 index 0000000..de1b69c --- /dev/null +++ b/Models/CusWeightLossProgress.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class CusWeightLossProgress + { + [Key] + public Guid WeeklyProgressId { get; set; } + public decimal? Weight { get; set; } + public DateTime RecordedAt { get; set; } = DateTime.UtcNow; + public Guid FKCusId { get; set; } + [ForeignKey("FKCusId")] + public virtual Customer Customer { get; set; } + public Guid CustomerTargetId { get; set; } + [ForeignKey("CustomerTargetId")] + public virtual CustomerTarget CustomerTarget { get; set; } + public bool IsActive { get; set; } = true; + } +} diff --git a/Models/Customer.cs b/Models/Customer.cs new file mode 100644 index 0000000..f6b9b17 --- /dev/null +++ b/Models/Customer.cs @@ -0,0 +1,47 @@ +using Fitness_core_BE.Models; +using System.ComponentModel.DataAnnotations.Schema; +using System.ComponentModel.DataAnnotations; +using System.Collections.Generic; + +namespace Fitness_core_BE.Models +{ + public class Customer + { + [Key] + public Guid CusId { get; set; } + + public string? CusName { get; set; } + public string? PushTokenId { get; set; } + // Points at a UserDocument uploaded via POST /customer/documents. No EF relationship + // is configured (kept as a plain id) since we only ever need the id itself here — the + // mobile app builds the display URL client-side via docService/{id}. + public int? FKProfileImageDocId { get; set; } + public string? Gender { get; set; } + public DateOnly? BoD { get; set; } + public string? PrimaryPH { get; set; } + public bool IsPrimaryPHVerfied { get; set; } = false; + public string? SecondaryPH { get; set; } + public bool IsSecondaryPHVerfied { get; set; } = false; + public string? Email { get; set; } + public bool IsEmailVerfied { get; set; } = false; + public string? NIC { get; set; } + public string? Address { get; set; } + public Guid FKUserId { get; set; } + [ForeignKey("FKUserId")] + public virtual User User { get; set; } + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Legacy single-gym lock mirror. New physical-access decisions are stored on the + // tenant-scoped AccessProfile so one customer can have different access state at + // different gyms. + public bool IsLocked { get; set; } = false; + public string? LockReason { get; set; } // "Manual" | "Overdue" + public DateTime? LockedAt { get; set; } + + public virtual ICollection CustomerWorkouts { get; set; } + public virtual ICollection CustomerMeals { get; set; } + public virtual ICollection CustomerTargets { get; set; } + public virtual ICollection CustomerSubscriptions { get; set; } + } +} diff --git a/Models/CustomerAccess.cs b/Models/CustomerAccess.cs new file mode 100644 index 0000000..4b77d2f --- /dev/null +++ b/Models/CustomerAccess.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class CustomerAccess + { + public int Id { get; set; } + public string Name { get; set; } + public Guid FKDeviceId { get; set; } + [ForeignKey("FKDeviceId")] + public AccessDevice AccessDevice { get; set; } + public Guid FKCustomerId { get; set; } + [ForeignKey(nameof(FKCustomerId))] + public Customer Customer { get; set; } + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/CustomerCards.cs b/Models/CustomerCards.cs new file mode 100644 index 0000000..4630cd3 --- /dev/null +++ b/Models/CustomerCards.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class CustomerCards + { + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public Guid JobId { get; set; } + + // Tenant-bucket entity: this FK holds a TenantCustomerLinkId (not the central + // Customer.CusId directly) -- see Models/TenantCustomerLink.cs. + public Guid? FK_CustomerId { get; set; } + [ForeignKey("FK_CustomerId")] + public virtual TenantCustomerLink? TenantCustomerLink { get; set; } + + public Guid? FK_ClientId { get; set; } + [ForeignKey("FK_ClientId")] + public virtual Client? Client { get; set; } + + public string? WalletId { get; set; } + public string? CardId { get; set; } + public string? CardNumber { get; set; } + public string? CardType { get; set; } + public string? CardDate { get; set; } + public bool IsActive { get; set; } = false; + public bool IsPrimary { get; set; } = false; + public int Priority { get; set; } = 0; + public string? NickName { get; set; } + + /// Member's explicit opt-in to store this card for auto-deduct charges. + public bool ConsentGiven { get; set; } = false; + public DateTime? ConsentAt { get; set; } + } +} diff --git a/Models/CustomerHealth.cs b/Models/CustomerHealth.cs new file mode 100644 index 0000000..65a121e --- /dev/null +++ b/Models/CustomerHealth.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class CustomerHealth + { + [Key] + public Guid HealthId { get; set; } + public string? BloodGroup { get; set; } + public string? Disabilities { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public Guid FKCusId { get; set; } + [ForeignKey("FKCusId")] + public virtual Customer Customer { get; set; } + public bool IsActive { get; set; } + } +} diff --git a/Models/CustomerInvoice.cs b/Models/CustomerInvoice.cs new file mode 100644 index 0000000..04b490c --- /dev/null +++ b/Models/CustomerInvoice.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class CustomerInvoice + { + [Key] + public Guid CustomerInvoiceId { get; set; } + public Guid FKCustomerSubscriptionId { get; set; } + [ForeignKey("FKCustomerSubscriptionId")] + public virtual CustomerSubscription CustomerSubscription { get; set; } + + public Guid FKClientId { get; set; } + [ForeignKey("FKClientId")] + public virtual Client Client { get; set; } + + public Guid FKUserId { get; set; } + [ForeignKey("FKUserId")] + public virtual User User { get; set; } + + public decimal Amount { get; set; } + public string Status { get; set; } = "Pending"; + + // Approval gate for the renewal workflow: false means this invoice was auto-generated by + // the daily renewal sweep and is awaiting staff approve/change-plan/reject. Independent of + // Status and of CustomerSubscription.IsActive. Defaults true so every other invoice- + // creation path (onboarding, plan change, manual billing) is unaffected. + public bool IsActive { get; set; } = true; + + public DateTime CustomerInvoiceDate { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/CustomerInvoiceItem.cs b/Models/CustomerInvoiceItem.cs new file mode 100644 index 0000000..d3596bf --- /dev/null +++ b/Models/CustomerInvoiceItem.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class CustomerInvoiceItem + { + [Key] + public Guid CustomerInvoiceItemId { get; set; } + public Guid FKCustomerInvoiceId { get; set; } + [ForeignKey("FKCustomerInvoiceId")] + public virtual CustomerInvoice CustomerInvoice { get; set; } + public string? ItemName { get; set; } + public string? ItemDescription { get; set; } + public decimal ItemPrice { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/CustomerMeal.cs b/Models/CustomerMeal.cs new file mode 100644 index 0000000..dcb7853 --- /dev/null +++ b/Models/CustomerMeal.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class CustomerMeal + { + [Key] + public Guid CustomerMealId { get; set; } + + // Tenant-bucket entity: this FK holds a TenantCustomerLinkId (not the central + // Customer.CusId directly) -- see Models/TenantCustomerLink.cs. + public Guid FKCustomerId { get; set; } + [ForeignKey("FKCustomerId")] + public virtual TenantCustomerLink? TenantCustomerLink { get; set; } + + public Guid FKMealPlanId { get; set; } + [ForeignKey("FKMealPlanId")] + public virtual MealPlan? MealPlan { get; set; } + + public bool IsActive { get; set; } = true; + public DateTime AssignedDate { get; set; } = DateTime.UtcNow; + public DateTime? CompletedDate { get; set; } + public bool IsFollowing { get; set; } = false; + public string? Notes { get; set; } + } +} diff --git a/Models/CustomerNotification.cs b/Models/CustomerNotification.cs new file mode 100644 index 0000000..9c014ea --- /dev/null +++ b/Models/CustomerNotification.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + // A persisted, in-app history of pushes sent to a customer (workout/meal plan assigned, + // a gym feed post, etc.) — backs the mobile Notification tab, independent of whether the + // device actually received/displayed the push itself. + public class CustomerNotification + { + [Key] + public Guid CustomerNotificationId { get; set; } + + public Guid FKCustomerId { get; set; } + [ForeignKey("FKCustomerId")] + public virtual Customer? Customer { get; set; } + + public string Title { get; set; } = string.Empty; + public string Body { get; set; } = string.Empty; + + // "WorkoutPlanAssigned" | "MealPlanAssigned" | "FeedPost" — free text, not an enum, so + // new notification kinds don't need a migration. + public string NotificationType { get; set; } = string.Empty; + + // Mirrors PushNavigationData's contract in the mobile app's pushNotification.ts, so a + // tap on this notification in the list can navigate the same way a live push would. + public string? RouteName { get; set; } + public string? RouteParamsJson { get; set; } + + public bool IsRead { get; set; } = false; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/CustomerPlan.cs b/Models/CustomerPlan.cs new file mode 100644 index 0000000..3c6cd08 --- /dev/null +++ b/Models/CustomerPlan.cs @@ -0,0 +1,67 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class CustomerPlan + { + [Key] + public Guid CustomerPlanId { get; set; } + public Guid FKClientId { get; set; } + [ForeignKey("FKClientId")] + public virtual Client Client { get; set; } + public string? PlanName { get; set; } + public string? PlanDescription { get; set; } + public decimal PlanPrice { get; set; } + + /// Whether this plan charges a one-time fee when a new subscription starts. + public bool HasOnboardingFee { get; set; } = false; + + /// One-time amount added to the first invoice/installment. + public decimal OnboardingFeeAmount { get; set; } = 0m; + + /// Used when DurationMode == "Rolling"; 0 for FixedRange plans. + public int DurationInDays { get; set; } + + /// "Individual" | "Group" (couple/family — one subscription covers MemberLimit members). + public string PlanScope { get; set; } = "Individual"; + + /// 1 for Individual; >= 2 for Group (couple = 2). + public int MemberLimit { get; set; } = 1; + + /// "Rolling" (DurationInDays from join date) | "FixedRange" (ValidFrom–ValidTo). + public string DurationMode { get; set; } = "Rolling"; + + public DateTime? ValidFrom { get; set; } + public DateTime? ValidTo { get; set; } + + /// Owner decides: when true the price splits into InstallmentCount scheduled parts. + public bool AllowSplitPayments { get; set; } = false; + + /// Number of mostly-equal installments (1 = full payment upfront). + public int InstallmentCount { get; set; } = 1; + + /// Null = unlimited-access plan (existing gym behavior). A number = this plan + /// grants that many bookable Facility sessions per billing cycle (e.g. "4 pool sessions + /// a month") — enforced by the booking flow, not by this model. + public int? SessionsPerCycle { get; set; } + + /// "NoRollover" (unused sessions are forfeited when the cycle renews) | + /// "GracePeriod" (unused sessions stay usable for RolloverGraceDays after renewal). + /// Set per package at creation time, per owner's requirement. + public string SessionRolloverPolicy { get; set; } = "NoRollover"; + + /// Days into the new cycle that last cycle's unused sessions remain usable. + /// Only meaningful when SessionRolloverPolicy == "GracePeriod". + public int RolloverGraceDays { get; set; } = 0; + + /// "Prepaid" (pay before the cycle starts, existing behavior) | "Postpaid" + /// (use first, invoice at cycle end — reuses Client.LockAfterDaysOverdue / + /// OverdueLockJob to suspend access if that invoice goes unpaid past due). Asked once, + /// per package, at creation time per owner's requirement. + public string BillingTiming { get; set; } = "Prepaid"; + + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/CustomerPlanPlace.cs b/Models/CustomerPlanPlace.cs new file mode 100644 index 0000000..1cd6dd9 --- /dev/null +++ b/Models/CustomerPlanPlace.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + /// Many-to-many join: which Place(s) a CustomerPlan grants access to. A plan + /// with no rows here is unrestricted (reaches every active device) -- see + /// AccessDeviceService's allowed-device resolution. + public class CustomerPlanPlace + { + [Key] + public Guid CustomerPlanPlaceId { get; set; } + + public Guid FKCustomerPlanId { get; set; } + [ForeignKey(nameof(FKCustomerPlanId))] + public CustomerPlan CustomerPlan { get; set; } = null!; + + public Guid FKPlaceId { get; set; } + [ForeignKey(nameof(FKPlaceId))] + public Place Place { get; set; } = null!; + } +} diff --git a/Models/CustomerSubItem.cs b/Models/CustomerSubItem.cs new file mode 100644 index 0000000..a6350c9 --- /dev/null +++ b/Models/CustomerSubItem.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class CustomerSubItem + { + [Key] + public Guid CustomerSubItemId { get; set; } + + public Guid FKCustomerSubscriptionId { get; set; } + [ForeignKey("FKCustomerSubscriptionId")] + public virtual CustomerSubscription CustomerSubscription { get; set; } + + public string? SubItemName { get; set; } + public string? SubItemDescription { get; set; } + public decimal SubItemPrice { get; set; } + + // "OneTime" (charged once, e.g. via a supplemental invoice) | "Recurring" (carried + // forward onto the invoice each time this subscription renews/upgrades). + public string Frequency { get; set; } = "OneTime"; + + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/CustomerSubscription.cs b/Models/CustomerSubscription.cs new file mode 100644 index 0000000..f8b13db --- /dev/null +++ b/Models/CustomerSubscription.cs @@ -0,0 +1,61 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class CustomerSubscription + { + [Key] + public Guid SubscriptionId { get; set; } + + public Guid FKCustomerPlanId { get; set; } + [ForeignKey("FKCustomerPlanId")] + public virtual CustomerPlan CustomerPlan { get; set; } + + // Tenant-bucket entity: this FK holds a TenantCustomerLinkId (not the central + // Customer.CusId directly) -- see Models/TenantCustomerLink.cs. + public Guid FKCustomerId { get; set; } + [ForeignKey("FKCustomerId")] + public virtual TenantCustomerLink TenantCustomerLink { get; set; } + + public DateTime OnboardDate { get; set; } = DateTime.UtcNow; + + // Set once, at creation, and never touched again -- see RenewalDate for the field that + // actually reflects the subscription's current cycle end / renewal-eligibility date. + public DateTime? ExpiryDate { get; set; } + + // Advances on every approved renewal cycle; every "is this due/expiring/current" query, + // filter, sort or status display should read this instead of ExpiryDate. + public DateTime? RenewalDate { get; set; } + + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Set together at assignment time; "was a discount given" is DiscountValue.HasValue. + public string? DiscountType { get; set; } // "Percentage" | "Flat" + public decimal? DiscountValue { get; set; } + + /// Member has consented to auto-deduct renewals from FKAutoChargeCardId. + public bool AutoChargeEnabled { get; set; } = false; + public Guid? FKAutoChargeCardId { get; set; } + [ForeignKey("FKAutoChargeCardId")] + public virtual CustomerCards? AutoChargeCard { get; set; } + + // Session-count tracking for CustomerPlan.SessionsPerCycle plans (null for unlimited + // gym-access plans). SessionsUsedThisCycle resets on renewal; SessionsCarriedOver holds + // unused sessions brought forward when CustomerPlan.SessionRolloverPolicy == "GracePeriod" + // and is cleared once RolloverGraceDays elapses. Booking creation increments + // SessionsUsedThisCycle instead of duplicating this counter per facility. + public int SessionsUsedThisCycle { get; set; } = 0; + public int SessionsCarriedOver { get; set; } = 0; + + // Set at renewal approval time when CustomerPlan.SessionRolloverPolicy == "GracePeriod" + // (now + RolloverGraceDays); null when there's nothing carried over. Booking creation + // treats SessionsCarriedOver as spent/expired once this passes, even though the column + // itself isn't zeroed until the next renewal -- see BookingService.CreateMemberBooking. + public DateTime? SessionsCarriedOverExpiresAt { get; set; } + + public virtual ICollection SubscriptionMembers { get; set; } + public virtual ICollection SubscriptionInstallments { get; set; } + } +} diff --git a/Models/CustomerTarget.cs b/Models/CustomerTarget.cs new file mode 100644 index 0000000..4b00ceb --- /dev/null +++ b/Models/CustomerTarget.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class CustomerTarget + { + [Key] + public Guid CustomerTargetId { get; set; } + public Guid FKCusId { get; set; } + [ForeignKey("FKCusId")] + public virtual Customer Customer { get; set; } + public decimal? CurrentWeight { get; set; } + public decimal? CurrentHeight { get; set; } + public decimal? TargetWeight { get; set; } + public string? Goal { get; set; } + public string? TimePeriod { get; set; } + public string? PredictedNutritionsJson { get; set; } + public string? PredictedWeightsJson { get; set; } + public virtual ICollection WeeklyProgresses { get; set; } = new List(); + public bool IsChanged { get; set; } = false; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public bool IsActive { get; set; } = true; + } +} diff --git a/Models/CustomerTermsAcceptance.cs b/Models/CustomerTermsAcceptance.cs new file mode 100644 index 0000000..5760c74 --- /dev/null +++ b/Models/CustomerTermsAcceptance.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + /// Records that a customer accepted a specific gym's specific Terms version — + /// e.g. at QR-join time. Kept even after the gym publishes a newer version. + public class CustomerTermsAcceptance + { + [Key] + public Guid CustomerTermsAcceptanceId { get; set; } + + // Tenant-bucket entity: this FK holds a TenantCustomerLinkId (not the central + // Customer.CusId directly) -- see Models/TenantCustomerLink.cs. + public Guid FKCusId { get; set; } + [ForeignKey("FKCusId")] + public virtual TenantCustomerLink? TenantCustomerLink { get; set; } + + public Guid FKClientId { get; set; } + [ForeignKey("FKClientId")] + public virtual Client? Client { get; set; } + + public int Version { get; set; } + public DateTime AcceptedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/CustomerWorkout.cs b/Models/CustomerWorkout.cs new file mode 100644 index 0000000..81f8472 --- /dev/null +++ b/Models/CustomerWorkout.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class CustomerWorkout + { + [Key] + public Guid CustomerWorkoutId { get; set; } + + // Tenant-bucket entity: this FK holds a TenantCustomerLinkId (not the central + // Customer.CusId directly) -- see Models/TenantCustomerLink.cs. + public Guid FKCustomerId { get; set; } + [ForeignKey("FKCustomerId")] + public virtual TenantCustomerLink? TenantCustomerLink { get; set; } + + public Guid FKWorkoutPlanId { get; set; } + [ForeignKey("FKWorkoutPlanId")] + public virtual WorkoutPlan? WorkoutPlan { get; set; } + + public bool IsActive { get; set; } = true; + public DateTime AssignedDate { get; set; } = DateTime.UtcNow; + public DateTime? CompletedDate { get; set; } + public bool IsFollowing { get; set; } = false; + public string? Notes { get; set; } + public virtual ICollection ExerciseCompletions { get; set; } = new List(); + } +} diff --git a/Models/CustomerWorkoutExerciseCompletion.cs b/Models/CustomerWorkoutExerciseCompletion.cs new file mode 100644 index 0000000..562fb08 --- /dev/null +++ b/Models/CustomerWorkoutExerciseCompletion.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class CustomerWorkoutExerciseCompletion + { + [Key] + public Guid CustomerWorkoutExerciseCompletionId { get; set; } + public Guid FKCustomerWorkoutId { get; set; } + [ForeignKey("FKCustomerWorkoutId")] + public virtual CustomerWorkout? CustomerWorkout { get; set; } + public Guid FKWorkoutExerciseId { get; set; } + [ForeignKey("FKWorkoutExerciseId")] + public virtual WorkoutExercise? WorkoutExercise { get; set; } + public DateTime CompletedDate { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/DeviceCommand.cs b/Models/DeviceCommand.cs new file mode 100644 index 0000000..8e5d968 --- /dev/null +++ b/Models/DeviceCommand.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class DeviceCommand + { + [Key] + public Guid Id { get; set; } + public Guid FKClientId { get; set; } + [ForeignKey("FKClientId")] + public Client Client { get; set; } + public Guid? FKDeviceId { get; set; } + [ForeignKey("FKDeviceId")] + public AccessDevice? AccessDevice { get; set; } + + public Guid? FKAccessDeviceBindingId { get; set; } + [ForeignKey(nameof(FKAccessDeviceBindingId))] + public AccessDeviceBinding? AccessDeviceBinding { get; set; } + + // e.g. "AddUser", "WhiteListMember", "BlackListMember" + public string CommandType { get; set; } + public string PayloadJson { get; set; } + + // "Pending" / "InProgress" / "Completed" / "Failed" + public string Status { get; set; } = "Pending"; + public string? ResultJson { get; set; } + public int DesiredVersion { get; set; } + public int AttemptCount { get; set; } + public int MaxAttempts { get; set; } = 5; + public DateTime? LeaseExpiresAt { get; set; } + public DateTime? NextAttemptAt { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? ProcessedAt { get; set; } + } +} diff --git a/Models/DevicePricingConfig.cs b/Models/DevicePricingConfig.cs new file mode 100644 index 0000000..5148418 --- /dev/null +++ b/Models/DevicePricingConfig.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fitness_core_BE.Models +{ + // Single global row (admin-editable) for access-control-device fees. + // Not per-plan: every gym pays the same device rates in the source pricing model. + public class DevicePricingConfig + { + [Key] + public Guid DevicePricingConfigId { get; set; } + + public decimal DeviceRentalFeePerMonth { get; set; } = 0; + public decimal DeviceInstallationFee { get; set; } = 0; + public decimal DeviceConfigureExistingFee { get; set; } = 0; + + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/DocumentTypes.cs b/Models/DocumentTypes.cs new file mode 100644 index 0000000..9ce7e87 --- /dev/null +++ b/Models/DocumentTypes.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fitness_core_BE.Models +{ + public class DocumentTypes + { + [Key] + public int DocumentId { get; set; } + public string DocumentType { get; set; } + } +} diff --git a/Models/Enums/BeneficiaryType.cs b/Models/Enums/BeneficiaryType.cs new file mode 100644 index 0000000..541ac6d --- /dev/null +++ b/Models/Enums/BeneficiaryType.cs @@ -0,0 +1,8 @@ +namespace Fitness_core_BE.Models.Enums +{ + public enum BeneficiaryType + { + Customer, + Client + } +} diff --git a/Models/Enums/BusinessType.cs b/Models/Enums/BusinessType.cs new file mode 100644 index 0000000..357b1c5 --- /dev/null +++ b/Models/Enums/BusinessType.cs @@ -0,0 +1,10 @@ +namespace Fitness_core_BE.Models.Enums +{ + public enum BusinessType +{ + institute = 1, + cauching = 2, + productSeller = 3 + + } +} diff --git a/Models/Enums/CustomerOnboardingStatus.cs b/Models/Enums/CustomerOnboardingStatus.cs new file mode 100644 index 0000000..5c4f223 --- /dev/null +++ b/Models/Enums/CustomerOnboardingStatus.cs @@ -0,0 +1,9 @@ +namespace Fitness_core_BE.Models.Enums +{ + public enum CustomerOnboardingStatus + { + Pending = 1, + Approved = 2, + Rejected = 3 + } +} diff --git a/Models/Enums/FirstChargeStatus.cs b/Models/Enums/FirstChargeStatus.cs new file mode 100644 index 0000000..6cee3a1 --- /dev/null +++ b/Models/Enums/FirstChargeStatus.cs @@ -0,0 +1,9 @@ +namespace Fitness_core_BE.Models.Enums +{ + public enum FirstChargeStatus + { + Pending = 1, + Succeeded = 2, + Failed = 3 + } +} diff --git a/Models/Enums/KycStatus.cs b/Models/Enums/KycStatus.cs new file mode 100644 index 0000000..8542932 --- /dev/null +++ b/Models/Enums/KycStatus.cs @@ -0,0 +1,9 @@ +namespace Fitness_core_BE.Models.Enums +{ + public enum KycStatus + { + Pending = 1, + Approved = 2, + Rejected = 3 + } +} diff --git a/Models/Enums/OnboardingStatus.cs b/Models/Enums/OnboardingStatus.cs new file mode 100644 index 0000000..ed416d7 --- /dev/null +++ b/Models/Enums/OnboardingStatus.cs @@ -0,0 +1,11 @@ +namespace Fitness_core_BE.Models.Enums +{ + public enum OnboardingStatus + { + New = 1, + Called = 2, + Approved = 3, + Rejected = 4, + Activated = 5 + } +} diff --git a/Models/Enums/PaymentTrack.cs b/Models/Enums/PaymentTrack.cs new file mode 100644 index 0000000..db99fa7 --- /dev/null +++ b/Models/Enums/PaymentTrack.cs @@ -0,0 +1,8 @@ +namespace Fitness_core_BE.Models.Enums +{ + public enum PaymentTrack + { + OwnGateway = 1, + HexaFitness = 2 + } +} diff --git a/Models/Enums/PayoutBatchStatus.cs b/Models/Enums/PayoutBatchStatus.cs new file mode 100644 index 0000000..c0e03d2 --- /dev/null +++ b/Models/Enums/PayoutBatchStatus.cs @@ -0,0 +1,9 @@ +namespace Fitness_core_BE.Models.Enums +{ + public enum PayoutBatchStatus + { + Pending = 1, + Processed = 2, + Failed = 3 + } +} diff --git a/Models/Enums/PriorityLevel.cs b/Models/Enums/PriorityLevel.cs new file mode 100644 index 0000000..5459899 --- /dev/null +++ b/Models/Enums/PriorityLevel.cs @@ -0,0 +1,9 @@ +namespace Fitness_core_BE.Models.Enums +{ + public enum PriorityLevel + { + Low = 1, + Medium = 2, + High = 3 + } +} diff --git a/Models/Enums/ProgressGoalType.cs b/Models/Enums/ProgressGoalType.cs new file mode 100644 index 0000000..f84208e --- /dev/null +++ b/Models/Enums/ProgressGoalType.cs @@ -0,0 +1,15 @@ +namespace Fitness_core_BE.Models.Enums +{ + public enum ProgressGoalType + { + Loss, + Gain, + Maintain, + Strength, + Endurance, + AthleticPerformance, + StayFit, + Flexibility, + Beginner + } +} diff --git a/Models/Enums/Sex.cs b/Models/Enums/Sex.cs new file mode 100644 index 0000000..c6acdba --- /dev/null +++ b/Models/Enums/Sex.cs @@ -0,0 +1,8 @@ +namespace Fitness_core_BE.Models.Enums +{ + public enum Sex + { + Male, + Female + } +} diff --git a/Models/Enums/TicketStatusEnum.cs b/Models/Enums/TicketStatusEnum.cs new file mode 100644 index 0000000..9aa69e8 --- /dev/null +++ b/Models/Enums/TicketStatusEnum.cs @@ -0,0 +1,9 @@ +namespace Fitness_core_BE.Models.Enums +{ + public enum TicketStatusEnum + { + Pending = 1, + Open = 2, + Closed = 3 + } +} diff --git a/Models/Enums/WalletEntryType.cs b/Models/Enums/WalletEntryType.cs new file mode 100644 index 0000000..f44e45d --- /dev/null +++ b/Models/Enums/WalletEntryType.cs @@ -0,0 +1,8 @@ +namespace Fitness_core_BE.Models.Enums +{ + public enum WalletEntryType + { + Credit = 1, + Debit = 2 + } +} diff --git a/Models/Facility.cs b/Models/Facility.cs new file mode 100644 index 0000000..4364a82 --- /dev/null +++ b/Models/Facility.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + /// A bookable area of the club — the gym floor, the pool, a court, a class type + /// (karate, etc). Gym-floor access itself keeps working through the existing + /// CustomerPlan/Attendance flow unchanged; Facility/FacilitySession/Booking below are for + /// the new capacity-limited, session-based areas (pool, courts, classes). + public class Facility + { + [Key] + public Guid FacilityId { get; set; } + public Guid FKClientId { get; set; } + [ForeignKey(nameof(FKClientId))] + public Client Client { get; set; } + + public string Name { get; set; } = string.Empty; + + /// "Gym" | "Pool" | "Court" | "Class" — informational grouping for the admin + /// UI; booking rules themselves live on FacilitySession/CustomerPlan, not on this type. + public string FacilityType { get; set; } = "Class"; + + public string? Description { get; set; } + + /// Default per-session headcount limit; a specific FacilitySession can override. + public int DefaultCapacity { get; set; } = 1; + + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/FacilitySession.cs b/Models/FacilitySession.cs new file mode 100644 index 0000000..dcf71aa --- /dev/null +++ b/Models/FacilitySession.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + /// One scheduled, bookable occurrence of a Facility (e.g. "Pool, Tue 6-7pm"). + /// Capacity is enforced by counting non-cancelled Bookings against this row, not by a + /// denormalized counter, to avoid drift between the two. + public class FacilitySession + { + [Key] + public Guid FacilitySessionId { get; set; } + + public Guid FKFacilityId { get; set; } + [ForeignKey(nameof(FKFacilityId))] + public Facility Facility { get; set; } + + public Guid FKClientId { get; set; } + [ForeignKey(nameof(FKClientId))] + public Client Client { get; set; } + + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + + /// Overrides Facility.DefaultCapacity when set; null = use the facility default. + public int? Capacity { get; set; } + + public bool IsCancelled { get; set; } = false; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public virtual ICollection Bookings { get; set; } + } +} diff --git a/Models/FeedLike.cs b/Models/FeedLike.cs new file mode 100644 index 0000000..0d099b9 --- /dev/null +++ b/Models/FeedLike.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class FeedLike +{ + [Key] + public Guid LikeId { get; set; } + public Guid FKUserId { get; set; } + [ForeignKey("FKUserId")] + public virtual User User { get; set; } + + public Guid FKFeedPostId { get; set; } + [ForeignKey("FKFeedPostId")] + public virtual FeedPost FeedPost { get; set; } + + } +} diff --git a/Models/FeedPost.cs b/Models/FeedPost.cs new file mode 100644 index 0000000..4c540c9 --- /dev/null +++ b/Models/FeedPost.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class FeedPost +{ + [Key] + public Guid FeedPostId { get; set; } + public string? Caption { get; set; } + public string? Description { get; set; } + public int? FkDocId { get; set; } + public Guid FKUserId { get; set; } + [ForeignKey("FKUserId")] + public virtual User User { get; set; } + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/GymRole.cs b/Models/GymRole.cs new file mode 100644 index 0000000..85763a7 --- /dev/null +++ b/Models/GymRole.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + // A gym owner's custom, per-tenant staff role. Blank slate — creating a role grants + // zero permissions; the owner assigns them explicitly via GymRolePermission afterward. + public class GymRole + { + [Key] + public Guid GymRoleId { get; set; } + + public Guid FKClientId { get; set; } + [ForeignKey("FKClientId")] + public virtual Client Client { get; set; } + + public string Name { get; set; } = string.Empty; + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public virtual ICollection Permissions { get; set; } = new List(); + } +} diff --git a/Models/GymRolePermission.cs b/Models/GymRolePermission.cs new file mode 100644 index 0000000..02a6f82 --- /dev/null +++ b/Models/GymRolePermission.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + // Join row: a GymRole is granted access to one NavItem/permission key. + // Composite key (FKGymRoleId, NavItemKey) is configured in TenantDbContext.OnModelCreating. + public class GymRolePermission + { + public Guid FKGymRoleId { get; set; } + [ForeignKey("FKGymRoleId")] + public virtual GymRole GymRole { get; set; } + + // NavItem is central-bucket (global nav catalog); this FK is a plain scalar in + // TenantDbContext (see Ignore config there) -- resolve the label from NavItemKey + // against CentralDbContext.NavItems when actually needed. + public string NavItemKey { get; set; } = string.Empty; + [ForeignKey("NavItemKey")] + public virtual NavItem NavItem { get; set; } + } +} diff --git a/Models/IOs/AdminSuggestionDtos.cs b/Models/IOs/AdminSuggestionDtos.cs new file mode 100644 index 0000000..e480936 --- /dev/null +++ b/Models/IOs/AdminSuggestionDtos.cs @@ -0,0 +1,9 @@ +namespace Fitness_core_BE.Models.IOs +{ + public record AdminExerciseInput(string Name, string? BreathIn, string? BreathOut, string? Position, string? Reps, int? Sets, string? Duration, string? VideoUrl, string? Description); + public record AdminWorkoutDayInput(string Day, string? Description, string? SuitableFor, string? Duration, string? WarmUp, string? Cooldown, List Exercises); + public record AdminWorkoutSuggestionRequest(string Name, string Category, string? CategoryDescription, string? Description, List Days); + public record AdminFoodInput(string Name, string? Quantity); + public record AdminMealInput(string Name, string? Description, decimal? Calories, decimal? Protein, decimal? Carbohydrates, decimal? Fats, string? ServingSize, string? PreparationNotes, List Foods); + public record AdminMealSuggestionRequest(string Name, string Category, string? CategoryDescription, string? Description, List Meals); +} diff --git a/Models/IOs/ApiRequest.cs b/Models/IOs/ApiRequest.cs new file mode 100644 index 0000000..9b77d8d --- /dev/null +++ b/Models/IOs/ApiRequest.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models.IOs +{ + [NotMapped] + public class ApiRequest + { + public string FunctionName { get; set; } + + public object Payload { get; set; } + + public string Reference { get; set; } + } +} diff --git a/Models/IOs/ApiResponse.cs b/Models/IOs/ApiResponse.cs new file mode 100644 index 0000000..c2f257a --- /dev/null +++ b/Models/IOs/ApiResponse.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.ComponentModel.DataAnnotations; + +namespace Fitness_core_BE.Models.IOs +{ + [NotMapped] + public class ApiResponse + { + public int StatusCode { get; set; } + [Required] + public string Message { get; set; } + [Required] + public T Data { get; set; } + } +} diff --git a/Models/IOs/ClientDtos.cs b/Models/IOs/ClientDtos.cs new file mode 100644 index 0000000..2caf49c --- /dev/null +++ b/Models/IOs/ClientDtos.cs @@ -0,0 +1,80 @@ +namespace Fitness_core_BE.Models.IOs +{ + public record PagedResult(List Items, int Total, int Page, int PageSize); //commonly tabel use + + // Shared OTP-send response shape -- used by the client's own contact-detail-change OTP flow + // (ClientService.SendContactOtp via AuthOtpResponseParser), not onboarding-specific despite + // originally living in that file. + public record SendOtpResult(string ReferenceNumber, int ExpiresInSeconds, int ResendAllowedInSeconds); + + // ---------- Admin: client directory ---------- + public record ClientSummary(Guid ClientId, string? ClientName, string? City, string? PaymentTrack, bool IsActive, DateTime CreatedAt); + public record ClientDirectoryQuery(string? Search, int Page = 1, int PageSize = 20); + + // ---------- Admin: access-device pairing & status ---------- + public record PairingCodeResult(string Code, DateTime ExpiresAt); + + // IsOnline is inferred from the client's on-prem bridge agent's last heartbeat — the cloud + // never talks to a device directly. AccessDevice isn't tied to a specific agent, so every + // device under a client shares the same IsOnline signal (see AdminClientDirectoryService). + public record AdminAgentDto(Guid AgentId, string Name, bool IsActive, DateTime? LastSeenAt, bool IsOnline, DateTime CreatedAt); + public record AdminDeviceDto( + Guid DeviceId, + string DeviceName, + bool IsActive, + DateTime? LastSeenAt, + bool IsOnline, + DateTime? PresenceExpiresAt, + bool IsClaimed, + DateTime CreatedAt); + public record AdminClientDevicesResult(List Agents, List Devices); + + // ---------- Client's own customers / staff ---------- + public record CreateCustomerWithPlansRequest( + string Fullname, string UserName, string Email, string MobileNumber, string? Nic, string Gender, string? Birthday, + List? WorkoutPlanIds, List? MealPlanIds, Guid? CustomerPlanId, + DateTime? OnboardDate, bool ChargePayment = true, + // Set true only on the resubmit after staff has confirmed "yes, link this existing + // platform customer to my gym" -- see ClientService.CreateCustomerWithPlans. + bool ConfirmLink = false, + string? DiscountType = null, decimal? DiscountValue = null); + + // MobileNumber (not PhoneNumber) -- must match the frontend's MemberInput.mobileNumber + // JSON key, same convention CreateCustomerWithPlansRequest above already uses. A prior + // mismatch here (PhoneNumber) silently bound to null on every edit, since ASP.NET Core's + // JSON binder does not fuzzy-match differently-named keys -- every customer phone edit was + // wiping the number instead of changing it. + public record UpdateCustomerByClientRequest( + string Fullname, string UserName, string Email, string? Nic, string? Gender, string? Birthday, + string? MobileNumber); + + // "Parent" path: starts a brand new subscription for a customer who was created without + // a plan, with them as the primary member. + public record AssignCustomerPlanRequest( + Guid CustomerPlanId, DateTime? ExpiryDate, DateTime? OnboardDate, bool ChargePayment = true, + string? DiscountType = null, decimal? DiscountValue = null); + + // Switches an existing Individual-scope subscription to a different Individual-scope plan. + public record ChangeMembershipPlanRequest(Guid CustomerPlanId); + + // ---------- Client's own gym profile (post-activation) ---------- + public record EquipmentCountDto(string Name, int Count); + public record UpdateGymProfileRequest( + string? BusinessName, string? Address, string? City, string? Description, + bool? HasAc, List? Equipment); + + // ---------- Gym-specific Terms & Conditions authoring ---------- + public record PublishGymTermsRequest(string Content); + + // ---------- Client's own contact detail change (OTP-verified) ---------- + public record SendContactOtpRequest(string Type, string Identifier); + public record VerifyContactOtpRequest(string Type, string Identifier, string ReferenceNumber, string OtpCode); + + // ---------- Member medical details unlock ---------- + public record MedicalUnlockRequest(string Password); + + // GymRoleId optional -- must belong to the caller's own client (validated in StaffService). + // Left null, the new staff member has no dashboard access until a role is assigned later. + public record CreateStaffRequest(string UserName, string Email, string PhoneNumber, string? StaffPosition, string? Department, DateTime? HireDate, Guid? GymRoleId = null); + public record UpdateStaffRequest(string? UserName, string? Email, string? PhoneNumber, string? StaffPosition, string? Department); +} diff --git a/Models/IOs/SmsDtos.cs b/Models/IOs/SmsDtos.cs new file mode 100644 index 0000000..63c11ac --- /dev/null +++ b/Models/IOs/SmsDtos.cs @@ -0,0 +1,17 @@ +namespace Fitness_core_BE.Models.IOs +{ + public class SmsRequest + { + public string api_key { get; set; } = string.Empty; + public string phone_number { get; set; } = string.Empty; + public string msg_text { get; set; } = string.Empty; + } + + public class OtpResponse + { + public int StatusCode { get; set; } + public string Message { get; set; } = string.Empty; + public string? ApiResponse { get; set; } + public string? Error { get; set; } + } +} diff --git a/Models/JobRunLog.cs b/Models/JobRunLog.cs new file mode 100644 index 0000000..3a35cb5 --- /dev/null +++ b/Models/JobRunLog.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fitness_core_BE.Models +{ + // One row per (client, sweep) a background job processed. Central, not tenant-scoped -- + // job history isn't gym-operational data, and needs to be visible from one place + // regardless of which tenant schema it's actually about. + public class JobRunLog + { + [Key] + public Guid JobRunLogId { get; set; } + + public string JobName { get; set; } = string.Empty; + public Guid FKClientId { get; set; } + public DateTime RunAtUtc { get; set; } = DateTime.UtcNow; + + // "Success" | "PartialFailure" | "Failed" + public string Status { get; set; } = string.Empty; + public int SubscriptionsRenewed { get; set; } + public string? ErrorMessage { get; set; } + } +} diff --git a/Models/LedgerEntry.cs b/Models/LedgerEntry.cs new file mode 100644 index 0000000..be45182 --- /dev/null +++ b/Models/LedgerEntry.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + // Manual/ad-hoc ledger line (e.g. rent, utilities, misc income) — a simple ledger for + // things not already captured elsewhere. The full P&L view merges these with derived + // entries from PaymentTransaction (member payments), ClientInvoice (platform fees) and + // StaffPayment (payroll), rather than duplicating those into this table. + public class LedgerEntry + { + [Key] + public Guid Id { get; set; } + + public Guid FKClientId { get; set; } + [ForeignKey("FKClientId")] + public virtual Client Client { get; set; } + + public string EntryType { get; set; } = "Expense"; // "Income" | "Expense" + public string Category { get; set; } = "Other"; + public string? Description { get; set; } + public decimal Amount { get; set; } + public DateTime EntryDate { get; set; } = DateTime.UtcNow; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/Meal.cs b/Models/Meal.cs new file mode 100644 index 0000000..d57e8d8 --- /dev/null +++ b/Models/Meal.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class Meal + { + [Key] + public Guid MealId { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public decimal? Calories { get; set; } + public decimal? Protein { get; set; } + public decimal? Carbohydrates { get; set; } + public decimal? Fats { get; set; } + public string? ServingSize { get; set; } + public string? MealItems { get; set; } // JSON: {"Chicken": {"qountity" : 10pc, "Kal" : "40kal"}, "Rice": "200g"} + public string? PreparationNotes { get; set; } + + public Guid FKMealPlanId { get; set; } + [ForeignKey("FKMealPlanId")] + public virtual MealPlan? MealPlan { get; set; } + + public bool IsActive { get; set; } = true; + public DateTime CreateDate { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/MealCategory.cs b/Models/MealCategory.cs new file mode 100644 index 0000000..7e6f885 --- /dev/null +++ b/Models/MealCategory.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fitness_core_BE.Models +{ + public class MealCategory + { + [Key] + public Guid MealCategoryId { get; set; } + public string Name { get; set; } + public string? Description { get; set; } + public bool IsActive { get; set; } = true; + public DateTime CreateDate { get; set; } = DateTime.UtcNow; + public virtual ICollection MealPlans { get; set; } + } +} diff --git a/Models/MealPlan.cs b/Models/MealPlan.cs new file mode 100644 index 0000000..514e24a --- /dev/null +++ b/Models/MealPlan.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class MealPlan + { + [Key] + public Guid MealPlanId { get; set; } + public string Name { get; set; } + public string? Description { get; set; } + public bool IsTemplate { get; set; } = false; + + public Guid FKMealCategoryId { get; set; } + [ForeignKey("FKMealCategoryId")] + public virtual MealCategory MealCategory { get; set; } + + public Guid? FKClientId { get; set; } + [ForeignKey("FKClientId")] + public virtual Client? Client { get; set; } + + public bool IsActive { get; set; } = true; + public DateTime CreateDate { get; set; } = DateTime.UtcNow; + + public virtual ICollection MealItems { get; set; } = new List(); + public virtual ICollection CustomerMeals { get; set; } = new List(); + } +} diff --git a/Models/NavItem.cs b/Models/NavItem.cs new file mode 100644 index 0000000..19c61b9 --- /dev/null +++ b/Models/NavItem.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fitness_core_BE.Models +{ + // Global reference table (not per-tenant) driving the gym-owner dashboard's sidebar. + // Key doubles as the permission key used by GymRolePermission and RequiresPermissionAttribute — + // this is a coarse, one-permission-per-nav-section system, so a separate PermissionKey + // column would just be a redundant mirror of Key. + public class NavItem + { + [Key] + public string Key { get; set; } = string.Empty; + + public string Label { get; set; } = string.Empty; + public string? ShortLabel { get; set; } + public string Href { get; set; } = string.Empty; + public string Icon { get; set; } = string.Empty; + public int SortOrder { get; set; } + + // Rows like "settings" are never grantable to staff, regardless of role — the owner + // always sees them, staff never does. + public bool IsOwnerOnly { get; set; } = false; + public bool IsActive { get; set; } = true; + } +} diff --git a/Models/PairingCode.cs b/Models/PairingCode.cs new file mode 100644 index 0000000..dd08e74 --- /dev/null +++ b/Models/PairingCode.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Models +{ + [Index(nameof(Code))] + public class PairingCode + { + [Key] + public Guid Id { get; set; } + public string Code { get; set; } + public Guid FKClientId { get; set; } + [ForeignKey("FKClientId")] + public Client Client { get; set; } + public DateTime ExpiresAt { get; set; } + public bool IsUsed { get; set; } = false; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/PaymentTransactions.cs b/Models/PaymentTransactions.cs new file mode 100644 index 0000000..444b240 --- /dev/null +++ b/Models/PaymentTransactions.cs @@ -0,0 +1,48 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Fitness_core_BE.Models.Enums; + +namespace Fitness_core_BE.Models +{ + public class PaymentTransaction + { + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public Guid AttemptId { get; set; } + + public DateTime? PayDate { get; set; } + public string PayStatus { get; set; } + public decimal PayAmount { get; set; } + public string? PayNote { get; set; } + + public Guid? FK_CardId { get; set; } + [ForeignKey("FK_CardId")] + public virtual CustomerCards? CustomerCard { get; set; } + + public Guid? FK_CustomerInvoiceId { get; set; } + [ForeignKey("FK_CustomerInvoiceId")] + public virtual CustomerInvoice? CustomerInvoice { get; set; } + + public Guid? FK_ClientInvoiceId { get; set; } + [ForeignKey("FK_ClientInvoiceId")] + public virtual ClientInvoice? ClientInvoice { get; set; } + + public Guid? FK_ClientId { get; set; } + [ForeignKey("FK_ClientId")] + public virtual Client? Client { get; set; } + + public Guid? FK_CustomerId { get; set; } + [ForeignKey("FK_CustomerId")] + public virtual Customer? Customer { get; set; } + + public BeneficiaryType? BeneficiaryType { get; set; } + public string? RefType { get; set; } = "installment"; + // How a manually-recorded (counter) payment was actually taken -- "Cash" | "Bank". + // Left null for gateway/auto-charge attempts, where the method is implied elsewhere. + public string? PaymentMethod { get; set; } + public string? BankRefNo { get; set; } + public string? PaymentReferenceId { get; set; } + public bool? IsActive { get; set; } = true; + public bool? IsApproved { get; set; } = false; + } +} diff --git a/Models/PayoutBatch.cs b/Models/PayoutBatch.cs new file mode 100644 index 0000000..13a6cb6 --- /dev/null +++ b/Models/PayoutBatch.cs @@ -0,0 +1,21 @@ +using Fitness_core_BE.Models.Enums; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class PayoutBatch + { + [Key] + public Guid Id { get; set; } + + public Guid FKClientId { get; set; } + [ForeignKey("FKClientId")] + public virtual Client Client { get; set; } + + public DateOnly ScheduledDate { get; set; } + public decimal Amount { get; set; } + public PayoutBatchStatus Status { get; set; } = PayoutBatchStatus.Pending; + public DateTime? ProcessedAt { get; set; } + } +} diff --git a/Models/Place.cs b/Models/Place.cs new file mode 100644 index 0000000..9867d36 --- /dev/null +++ b/Models/Place.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + /// A physical access zone within the club (e.g. "Pool Area", "Indoor Gym"), + /// each fitted with its own fingerprint/face terminal. CustomerPlan grants access to + /// one or more Places via CustomerPlanPlace; AccessDevice.FKPlaceId says which Place a + /// terminal sits in. Deliberately separate from Facility, which models capacity-limited + /// bookable sessions (pool/court/class bookings) -- Places are about which physical + /// machine a member's access profile should reach, not about booking a time slot. + public class Place + { + [Key] + public Guid PlaceId { get; set; } + + public Guid FKClientId { get; set; } + [ForeignKey(nameof(FKClientId))] + public Client Client { get; set; } + + [MaxLength(128)] + public string Name { get; set; } = string.Empty; + + [MaxLength(500)] + public string? Description { get; set; } + + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/Receipt/ReceiptModel.cs b/Models/Receipt/ReceiptModel.cs new file mode 100644 index 0000000..b631e28 --- /dev/null +++ b/Models/Receipt/ReceiptModel.cs @@ -0,0 +1,19 @@ +namespace Fitness_core_BE.Models.Receipt +{ + /// Flat data used to render a "GYM OS" receipt PDF and its email/SMS notifications. + public class ReceiptModel + { + public Guid ClientId { get; set; } + public string ReceiptNo { get; set; } = ""; + public DateTime Date { get; set; } = DateTime.UtcNow; + public string GymName { get; set; } = "GYM OS"; + public string MemberName { get; set; } = "Member"; + public string? MemberPhone { get; set; } + public string? MemberEmail { get; set; } + public string PlanName { get; set; } = "Membership"; + public decimal AmountPaid { get; set; } + public decimal InvoiceAmount { get; set; } + public decimal Balance { get; set; } + public string Currency { get; set; } = "LKR"; + } +} diff --git a/Models/Staff.cs b/Models/Staff.cs new file mode 100644 index 0000000..722a385 --- /dev/null +++ b/Models/Staff.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class Staff + { + [Key] + public Guid StaffId { get; set; } + + public Guid FKUserId { get; set; } + [ForeignKey("FKUserId")] + public virtual User User { get; set; } + + public Guid FKClientId { get; set; } + [ForeignKey("FKClientId")] + public virtual Client Client { get; set; } + + // Null = no role assigned yet = zero permissions (see PermissionService). + public Guid? FKGymRoleId { get; set; } + [ForeignKey("FKGymRoleId")] + public virtual GymRole? GymRole { get; set; } + + public string? StaffPosition { get; set; } + public string? Department { get; set; } + public DateTime HireDate { get; set; } = DateTime.UtcNow; + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/StaffPayRate.cs b/Models/StaffPayRate.cs new file mode 100644 index 0000000..0a7976f --- /dev/null +++ b/Models/StaffPayRate.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class StaffPayRate + { + [Key] + public Guid Id { get; set; } + + public Guid FKStaffId { get; set; } + [ForeignKey("FKStaffId")] + public virtual Staff Staff { get; set; } + + public string RateType { get; set; } = "Monthly"; // "Monthly" | "Hourly" + public decimal Amount { get; set; } + + public DateTime EffectiveFrom { get; set; } = DateTime.UtcNow; + public bool IsActive { get; set; } = true; + } +} diff --git a/Models/StaffPayment.cs b/Models/StaffPayment.cs new file mode 100644 index 0000000..14e2034 --- /dev/null +++ b/Models/StaffPayment.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class StaffPayment + { + [Key] + public Guid Id { get; set; } + + public Guid FKStaffId { get; set; } + [ForeignKey("FKStaffId")] + public virtual Staff Staff { get; set; } + + public decimal Amount { get; set; } + public string? Note { get; set; } + public DateTime PaidAt { get; set; } = DateTime.UtcNow; + + // The gym-owner (Client.FKUserId) or staff user who recorded this payment. + public Guid FKPaidByUserId { get; set; } + } +} diff --git a/Models/SubscriptionInstallment.cs b/Models/SubscriptionInstallment.cs new file mode 100644 index 0000000..7fdbbd2 --- /dev/null +++ b/Models/SubscriptionInstallment.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + /// + /// One scheduled part of a subscription's price (mostly-equal split, e.g. a + /// Rs. 6000 six-month plan → 6 monthly rows of Rs. 1000; the last row absorbs + /// rounding). Payments are allocated to installments in InstallmentNo order. + /// InstallmentCount = 1 plans get a single row due at onboarding. + /// + public class SubscriptionInstallment + { + [Key] + public Guid SubscriptionInstallmentId { get; set; } + + public Guid FKSubscriptionId { get; set; } + [ForeignKey("FKSubscriptionId")] + public virtual CustomerSubscription Subscription { get; set; } + + /// 1-based position in the schedule. + public int InstallmentNo { get; set; } + + public DateTime DueDate { get; set; } + public decimal Amount { get; set; } + public decimal PaidAmount { get; set; } = 0m; + + /// "Pending" | "Partial" | "Paid". + public string Status { get; set; } = "Pending"; + + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/SubscriptionMember.cs b/Models/SubscriptionMember.cs new file mode 100644 index 0000000..b1bfd0a --- /dev/null +++ b/Models/SubscriptionMember.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + /// + /// Links a customer to a subscription. Every subscription has one IsPrimary row + /// (mirroring CustomerSubscription.FKCustomerId); group plans add more rows up to + /// the plan's MemberLimit. The primary row is never removed. + /// + public class SubscriptionMember + { + [Key] + public Guid SubscriptionMemberId { get; set; } + + public Guid FKSubscriptionId { get; set; } + [ForeignKey("FKSubscriptionId")] + public virtual CustomerSubscription Subscription { get; set; } + + // Tenant-bucket entity: this FK holds a TenantCustomerLinkId (not the central + // Customer.CusId directly) -- see Models/TenantCustomerLink.cs. + public Guid FKCustomerId { get; set; } + [ForeignKey("FKCustomerId")] + public virtual TenantCustomerLink TenantCustomerLink { get; set; } + + public bool IsPrimary { get; set; } + public DateTime JoinedAt { get; set; } = DateTime.UtcNow; + public bool IsActive { get; set; } = true; + + /// + /// Whether the member is currently *using* this plan. Unassigning clears this and + /// leaves IsActive alone, so the member keeps their row in the gym's member list + /// (and their access/device enrollment) and the same button can re-assign them. + /// IsActive stays reserved for a real removal, which does hide the row. + /// + public bool IsPlanAssigned { get; set; } = true; + } +} diff --git a/Models/TenantCustomerLink.cs b/Models/TenantCustomerLink.cs new file mode 100644 index 0000000..58d12ed --- /dev/null +++ b/Models/TenantCustomerLink.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fitness_core_BE.Models +{ + // Tenant-DB only, one row per customer this gym has ever enrolled. Every tenant-local + // table that used to FK straight to the central Customer.CusId keeps doing so, unchanged + // -- TenantCustomerLinkId IS the central Customer.CusId (same GUID value), not a new + // surrogate key. That's deliberate: a fresh surrogate key would mean "customerId" holds + // a different value depending on whether it's about to query a central or a tenant table, + // which is exactly the kind of silent, hard-to-spot bug this split needs to avoid. EF/MySQL + // still can't enforce this as a real cross-schema FK constraint -- it's just data -- but the + // ID space stays uniform everywhere, and this row is also the local anchor tenant tables + // join against for cached display fields without a central round-trip. + public class TenantCustomerLink + { + [Key] + public Guid TenantCustomerLinkId { get; set; } // == central Customer.CusId + + // = the shared login User id (same GUID in Auth's Users table), cached. + public Guid CentralUserId { get; set; } + + public string? DisplayName { get; set; } + public string? PrimaryPhoneCached { get; set; } + public string? EmailCached { get; set; } + + // What a client-initiated delete flips. Central Customer.IsActive is never touched + // by a client-initiated delete -- see CustomerRepository.DeactivateCustomerForClient. + public bool IsActiveAtThisClient { get; set; } = true; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/Ticket.cs b/Models/Ticket.cs new file mode 100644 index 0000000..cac4568 --- /dev/null +++ b/Models/Ticket.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Fitness_core_BE.Models.Enums; + +namespace Fitness_core_BE.Models +{ + public class Ticket + { + [Key] + public Guid TicketId { get; set; } + public string MainReason { get; set; } + + public Guid? FKTicketCategoryId { get; set; } + [ForeignKey("FKTicketCategoryId")] + public virtual TicketCategory TicketCategory { get; set; } + public Guid? FKAssignerId { get; set; } + [ForeignKey("FKAssignerId")] + public virtual Admin Admin { get; set; } + public Guid FKUserId { get; set; } + [ForeignKey("FKUserId")] + public virtual User User { get; set; } + public TicketStatusEnum TicketStatus { get; set; } = TicketStatusEnum.Pending; + public int? UserRating { get; set; } = null; + public string? UserFeedback { get; set; } = null; + public DateTime? FeedbackAt { get; set; } = null; + public bool IsActive { get; set; } = true; + public DateTime CreateDate { get; set; } = DateTime.UtcNow; + public virtual ICollection Messages { get; set; } + } +} diff --git a/Models/TicketCategory.cs b/Models/TicketCategory.cs new file mode 100644 index 0000000..c946b28 --- /dev/null +++ b/Models/TicketCategory.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; +using Fitness_core_BE.Models.Enums; + +namespace Fitness_core_BE.Models +{ + public class TicketCategory + { + [Key] + public Guid TicketCategoryId { get; set; } + public string TicketCategoryName { get; set; } + public PriorityLevel PriorityLevel { get; set; } + public bool IsActive { get; set; } = true; + public DateTime CreateDate { get; set; } = DateTime.UtcNow; + public virtual ICollection Tickets { get; set; } + } +} diff --git a/Models/TicketMessage.cs b/Models/TicketMessage.cs new file mode 100644 index 0000000..3002059 --- /dev/null +++ b/Models/TicketMessage.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class TicketMessage + { + [Key] + public Guid MessageId { get; set; } + public bool IsSentByAdmin { get; set; } + public string Message { get; set; } + public string? AttachmentURL { get; set; } + public int? FKDocumentId { get; set; } + [ForeignKey("FKDocumentId")] + public virtual UserDocument UserDocument { get; set; } + public Guid FKTicketId { get; set; } + [ForeignKey("FKTicketId")] + public virtual Ticket Ticket { get; set; } + public bool IsActive { get; set; } = true; + public DateTime CreateDate { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/TicketStatus.cs b/Models/TicketStatus.cs new file mode 100644 index 0000000..a3f6b52 --- /dev/null +++ b/Models/TicketStatus.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; +using Fitness_core_BE.Models.Enums; + +namespace Fitness_core_BE.Models +{ + public class TicketStatus + { + [Key] + public Guid TicketStatusId { get; set; } + public string StatusName { get; set; } + public TicketStatusEnum StatusCode { get; set; } + public bool IsActive { get; set; } = true; + public DateTime CreateDate { get; set; } = DateTime.UtcNow; + public virtual ICollection Tickets { get; set; } + } +} diff --git a/Models/TimeBankCard.cs b/Models/TimeBankCard.cs new file mode 100644 index 0000000..fffad2f --- /dev/null +++ b/Models/TimeBankCard.cs @@ -0,0 +1,58 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + /// A prepaid time balance on an RFID card, deducted by actual use via a tap-in/ + /// tap-out swipe pattern (a 1-hour visit deducts 60 minutes, a 15-minute visit deducts 15 -- + /// see ActiveSessionStartedAt) — separate from Booking's per-session OTP flow. Can belong to + /// a member (FKCustomerId set, e.g. an add-on to their plan) or be sold standalone to a + /// walk-in against just the RFID tag. + public class TimeBankCard + { + [Key] + public Guid TimeBankCardId { get; set; } + + public Guid FKClientId { get; set; } + [ForeignKey(nameof(FKClientId))] + public Client Client { get; set; } + + // Tenant-bucket entity: holds a TenantCustomerLinkId, not the central Customer.CusId + // directly -- see Models/TenantCustomerLink.cs. Null for a card sold to a walk-in with + // no membership at all. + public Guid? FKCustomerId { get; set; } + [ForeignKey(nameof(FKCustomerId))] + public TenantCustomerLink? TenantCustomerLink { get; set; } + + [MaxLength(64)] + public string RfidTagId { get; set; } = string.Empty; + + public int TotalMinutesPurchased { get; set; } + public int MinutesRemaining { get; set; } + + public decimal AmountPaid { get; set; } = 0m; + public Guid? FKPaymentTransactionId { get; set; } + [ForeignKey(nameof(FKPaymentTransactionId))] + public PaymentTransaction? PaymentTransaction { get; set; } + + public DateTime PurchasedAt { get; set; } = DateTime.UtcNow; + + /// Null = balance never expires (spend it whenever); business can still set one. + public DateTime? ExpiresAt { get; set; } + + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Tap-in/tap-out state: null = not currently on the floor anywhere. A swipe with this + // null starts a session (tap-in, no deduction yet); a swipe with this set ends it + // (tap-out) and deducts the elapsed minutes. One open session per card at a time -- + // a second tap-in without a matching tap-out first is rejected by the service, not + // silently overwritten, so a lost/forgotten tap-out surfaces instead of losing time. + public DateTime? ActiveSessionStartedAt { get; set; } + public Guid? ActiveSessionFacilityId { get; set; } + [ForeignKey(nameof(ActiveSessionFacilityId))] + public Facility? ActiveSessionFacility { get; set; } + + public virtual ICollection UsageLogs { get; set; } + } +} diff --git a/Models/TimeBankCardUsageLog.cs b/Models/TimeBankCardUsageLog.cs new file mode 100644 index 0000000..98f818e --- /dev/null +++ b/Models/TimeBankCardUsageLog.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + /// One deduction event against a TimeBankCard's balance — an append-only ledger so + /// MinutesRemaining is always auditable/reconstructable, the same reasoning as LedgerEntry + /// for money. + public class TimeBankCardUsageLog + { + [Key] + public Guid TimeBankCardUsageLogId { get; set; } + + public Guid FKTimeBankCardId { get; set; } + [ForeignKey(nameof(FKTimeBankCardId))] + public TimeBankCard TimeBankCard { get; set; } + + public Guid FKClientId { get; set; } + [ForeignKey(nameof(FKClientId))] + public Client Client { get; set; } + + // Which facility this swipe/use was against -- not a FacilitySession, since a time-bank + // card is spent on open-ended usage (e.g. "swim for however long"), not a fixed + // pre-booked slot the way Booking is. + public Guid FKFacilityId { get; set; } + [ForeignKey(nameof(FKFacilityId))] + public Facility Facility { get; set; } + + public int MinutesDeducted { get; set; } + public int MinutesRemainingAfter { get; set; } + + public DateTime UsedAt { get; set; } = DateTime.UtcNow; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/UnlinkedDeviceEnrollment.cs b/Models/UnlinkedDeviceEnrollment.cs new file mode 100644 index 0000000..23fd581 --- /dev/null +++ b/Models/UnlinkedDeviceEnrollment.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + // A fingerprint enrolled directly on the physical access-control terminal (by gym + // staff, outside our system) that has no matching CustomerAccess yet. Surfaced by + // attendance ingestion (AgentService.IngestAttendanceAsync) whenever an incoming + // employeeNo/DeviceUserId has no CustomerAccess mapping for that device, so the + // client portal can offer it in a dropdown to link to a system Customer. + public class UnlinkedDeviceEnrollment + { + [Key] + public Guid Id { get; set; } + + public Guid FKDeviceId { get; set; } + [ForeignKey("FKDeviceId")] + public virtual AccessDevice AccessDevice { get; set; } + + public int DeviceUserId { get; set; } + [MaxLength(32)] + public string DeviceEmployeeNo { get; set; } = string.Empty; + public string? DeviceReportedName { get; set; } + [MaxLength(20)] + public string? DeviceUserType { get; set; } + public int FingerprintCount { get; set; } + + public DateTime DiscoveredAt { get; set; } = DateTime.UtcNow; + public DateTime LastSeenAt { get; set; } = DateTime.UtcNow; + public DateTime? LinkedAt { get; set; } + + public long? FKAccessProfileId { get; set; } + [ForeignKey(nameof(FKAccessProfileId))] + public virtual AccessProfile? AccessProfile { get; set; } + } +} diff --git a/Models/User.cs b/Models/User.cs new file mode 100644 index 0000000..db794e7 --- /dev/null +++ b/Models/User.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fitness_core_BE.Models +{ + public class User + { + [Key] + public Guid UserId { get; set; } + public string? UserName { get; set; } + public string? Email { get; set; } + public string? PhoneNumber { get; set; } + public bool IsActive { get; set; } = true; + public DateTime CreateDate { get; set; } = DateTime.UtcNow; + public virtual ICollection Tickets { get; set; } + public virtual ICollection Documents { get; set; } + } +} diff --git a/Models/UserDocument.cs b/Models/UserDocument.cs new file mode 100644 index 0000000..7444df5 --- /dev/null +++ b/Models/UserDocument.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class UserDocument + { + [Key] + public int DocumentId { get; set; } + public Guid? UserId { get; set; } + public int FkDocumentTypeId { get; set; } + [ForeignKey("FkDocumentTypeId")] + public DocumentTypes DocumentType { get; set; } + public string DocumentUrl { get; set; } + public string DocumentName { get; set; } + public bool IsActive { get; set; } = true; + public DateTime Created_At { get; set; } = DateTime.Now; + } +} diff --git a/Models/WalletLedgerEntry.cs b/Models/WalletLedgerEntry.cs new file mode 100644 index 0000000..46f102b --- /dev/null +++ b/Models/WalletLedgerEntry.cs @@ -0,0 +1,26 @@ +using Fitness_core_BE.Models.Enums; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class WalletLedgerEntry + { + [Key] + public Guid Id { get; set; } + + public Guid FKWalletId { get; set; } + [ForeignKey("FKWalletId")] + public virtual ClientWallet Wallet { get; set; } + + public WalletEntryType EntryType { get; set; } + public decimal Amount { get; set; } + + public Guid? RelatedCustomerInvoiceId { get; set; } + [ForeignKey("RelatedCustomerInvoiceId")] + public virtual CustomerInvoice? RelatedCustomerInvoice { get; set; } + + public string? Description { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/WorkoutCategory.cs b/Models/WorkoutCategory.cs new file mode 100644 index 0000000..4df2f33 --- /dev/null +++ b/Models/WorkoutCategory.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fitness_core_BE.Models +{ + public class WorkoutCategory + { + [Key] + public Guid WorkoutCategoryId { get; set; } + public string WorkoutCategoryName { get; set; } + public string? Description { get; set; } + public bool IsActive { get; set; } = true; + public DateTime CreateDate { get; set; } = DateTime.UtcNow; + public virtual ICollection WorkoutPlans { get; set; } + } +} diff --git a/Models/WorkoutExercise.cs b/Models/WorkoutExercise.cs new file mode 100644 index 0000000..82409fa --- /dev/null +++ b/Models/WorkoutExercise.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class WorkoutExercise + { + [Key] + public Guid WorkoutExerciseId { get; set; } + public Guid FKWorkoutItemId { get; set; } + [ForeignKey("FKWorkoutItemId")] + public virtual WorkoutItem? WorkoutItem { get; set; } + public string Name { get; set; } = string.Empty; + public string? MuscleGroup { get; set; } + public int? Sets { get; set; } + public string? Reps { get; set; } + public string? Description { get; set; } + public string? BreathIn { get; set; } + public string? BreathOut { get; set; } + public string? VideoUrl { get; set; } + public int SortOrder { get; set; } + public bool IsActive { get; set; } = true; + public DateTime CreateDate { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/WorkoutItem.cs b/Models/WorkoutItem.cs new file mode 100644 index 0000000..9cf9f97 --- /dev/null +++ b/Models/WorkoutItem.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class WorkoutItem + { + [Key] + public Guid WorkoutItemId { get; set; } + public string ExerciseDay { get; set; } = string.Empty; + public string? Description { get; set; } + public string? Exercises { get; set; } // JSON: {"Push-ups": "3 sets x 10 reps", "Squats": "4 sets x 15 reps"} + public string? HowToDo { get; set; } // Structured JSON + public string? DoURL { get; set; } + public Guid FKWorkoutPlanId { get; set; } + [ForeignKey("FKWorkoutPlanId")] + public virtual WorkoutPlan? WorkoutPlan { get; set; } + public string? Duration { get; set; } + public string? WormUp { get; set; } + public string? Cooldown { get; set; } + public bool IsActive { get; set; } = true; + public DateTime CreateDate { get; set; } = DateTime.UtcNow; + public DateTime? UpdatedAt { get; set; } + public virtual ICollection WorkoutExercises { get; set; } = new List(); + } +} diff --git a/Models/WorkoutPlan.cs b/Models/WorkoutPlan.cs new file mode 100644 index 0000000..5e28a52 --- /dev/null +++ b/Models/WorkoutPlan.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + public class WorkoutPlan + { + [Key] + public Guid WorkoutPlanId { get; set; } + public string WorkoutPlanName { get; set; } + public string? Description { get; set; } + public bool IsTemplate { get; set; } = false; + + public Guid FKWorkoutCategoryId { get; set; } + [ForeignKey("FKWorkoutCategoryId")] + public virtual WorkoutCategory WorkoutCategory { get; set; } + + public Guid? FKClientId { get; set; } + [ForeignKey("FKClientId")] + public virtual Client? Client { get; set; } + + public bool IsActive { get; set; } = true; + public DateTime CreateDate { get; set; } = DateTime.UtcNow; + + public virtual ICollection WorkoutItems { get; set; } = new List(); + public virtual ICollection CustomerWorkouts { get; set; } = new List(); + } +} diff --git a/PROJECT_REFERENCE.md b/PROJECT_REFERENCE.md new file mode 100644 index 0000000..9858f2c --- /dev/null +++ b/PROJECT_REFERENCE.md @@ -0,0 +1,1268 @@ +# Fitness Core Backend Reference + +## Purpose + +This file is a code-first reference for the current backend implementation in `Fitness_Core_BE`. +It covers: + +- project structure +- runtime configuration +- authentication +- response envelopes +- REST endpoints +- legacy RPC-style endpoints +- request and response DTOs +- helpers +- services, repositories, and models +- existing support files such as Postman collections and Markdown files + +This document reflects the codebase as currently implemented on July 6, 2026. + +## Project Overview + +The backend is an ASP.NET Core Web API project with: + +- Entity Framework Core + MySQL +- controller-based HTTP APIs +- a mixed API style: + - newer resource-oriented REST endpoints in `Controllers/ClientController.cs`, `Controllers/AdminController.cs`, and `Controllers/AgentController.cs` + - older function-dispatch endpoints in `Controllers/APIController.cs` +- SignalR chat hub at `/hubs/chat` +- helper-based JWT validation for normal user flows +- custom API-key authentication for on-prem agent flows +- AI orchestration services for meal/workout planning + +## Top-Level Structure + +- `Controllers/` + - HTTP API entrypoints +- `Services/` + - business logic +- `Services/FunctionHandler/` + - legacy function router layer for `ApiRequest` +- `Repos/` + - repository implementations +- `Interfaces/` + - repository and service contracts +- `Models/` + - EF entities, enums, IO contracts +- `DTOs/` + - request/response DTOs +- `Helpers/` + - auth, payload, response, crypto, parsing, and utility helpers +- `AI/` + - AI client and meal/workout plan orchestration +- `Infra/UoW/` + - unit-of-work abstraction +- `Hubs/` + - SignalR hub(s) +- `Migrations/` + - EF Core migrations +- `postman/` and `.postman/` + - API collections +- `README.md` + - current content is only a short placeholder + +## Runtime and DI Setup + +`Program.cs` configures: + +- controllers with `ReferenceHandler.IgnoreCycles` +- Swagger in development +- MySQL `AppDBContext` +- permissive CORS policy named `AllowAll` +- SignalR +- dependency injection for repositories, services, helpers, function handlers, agent auth, and AI services +- `app.MapControllers()` +- `app.MapHub("/hubs/chat")` + +## Authentication and Authorization + +### 1. Standard user/client/admin flows + +The custom action filter `JwtMiddleware.CustomMiddlewareAttribute` is used on protected endpoints. + +It accepts the token from: + +- `MobileAuthToken: Bearer ` request header, or +- `access_token` cookie + +It validates against: + +- `Jwt:PublicKey` +- `Jwt:Issuer` +- `Jwt:Audience` + +It populates: + +- `HttpContext.Items["UserId"]` +- `HttpContext.Items["Email"]` +- `HttpContext.Items["UserType"]` +- `HttpContext.Items["RoleId"]` + +### 2. Request-access token for onboarding + +`RequestAccessTokenHelper` creates a short-lived JWT used between OTP verification and onboarding request creation. + +Claims: + +- `MobileNumber` +- `Purpose = RequestAccess` + +Configuration: + +- `RequestAccessToken:SigningKey` +- `RequestAccessToken:ExpiresInMinutes` + +### 3. On-prem agent authentication + +The agent flow uses `X-Api-Key`. + +Implementation: + +- `Auth/AgentApiKeyAuthenticationHandler.cs` +- policy name: `AgentApiKey` + +Resolved server-side claims: + +- `AgentId` +- `FKClientId` + +Important behavior: + +- the backend never trusts `clientId` or `agentId` from the request body for authenticated agent endpoints +- the API key is stored hashed with SHA-256 via `ApiKeyHelper` + +## Standard Response Envelopes + +### Generic API envelope + +Most endpoints return: + +```json +{ + "statusCode": 200, + "message": "success", + "data": {} +} +``` + +Code types: + +- `Models/IOs/ApiResponse.cs` +- `Helpers/ApiResponseHelper.cs` + +### Legacy RPC request envelope + +Used by the old `POST /api/...` dispatcher endpoints: + +```json +{ + "functionName": "SomeFunction", + "payload": {}, + "reference": "optional caller reference" +} +``` + +Type: + +- `Models/IOs/ApiRequest.cs` + +## HTTP Surface + +## A. `APIController` legacy dispatcher (`/api`) + +This controller mixes one health endpoint, one document download endpoint, and many RPC-style POST endpoints. + +### A1. Direct endpoints + +| Method | Path | Auth | Request | Response | +|---|---|---|---|---| +| `GET` | `/api/status` | `CustomMiddleware` | none | `200 OK` with `{ status, timestamp }` | +| `GET` | `/api/docService/{docId}` | anonymous | route param `docId` | file download or error string | + +### A2. RPC dispatch endpoints + +All of these accept `ApiRequest` unless otherwise noted. + +| Method | Path | Auth | +|---|---|---| +| `POST` | `/api/ticket` | `CustomMiddleware` | +| `POST` | `/api/activitylog` | none | +| `POST` | `/api/client` | `CustomMiddleware` | +| `POST` | `/api/auth` | none | +| `POST` | `/api/VerifyOTP` | `CustomMiddleware` | +| `POST` | `/api/refreshToken` | anonymous | +| `POST` | `/api/logout` | anonymous | +| `POST` | `/api/staff` | `CustomMiddleware` | +| `POST` | `/api/clientinvoice` | `CustomMiddleware` | +| `POST` | `/api/customer` | `CustomMiddleware` | +| `POST` | `/api/customerinvoice` | `CustomMiddleware` | +| `POST` | `/api/customerplan` | `CustomMiddleware` | +| `POST` | `/api/customersubscription` | `CustomMiddleware` | +| `POST` | `/api/meal` | `CustomMiddleware` | +| `POST` | `/api/workout` | `CustomMiddleware` | +| `POST` | `/api/document` | `CustomMiddleware` | +| `POST` | `/api/feed` | `CustomMiddleware` | +| `POST` | `/api/accessdevice` | none | +| `POST` | `/api/attendance` | `CustomMiddleware` | +| `POST` | `/api/chat` | `CustomMiddleware` | +| `POST` | `/api/initChat` | `CustomMiddleware` | +| `POST` | `/api/payment` | `CustomMiddleware` | +| `POST` | `/api/clientstat` | `CustomMiddleware` | +| `POST` | `/api/register` | none | + +### A3. Function names supported by each legacy dispatcher + +#### `/api/activitylog` + +- `LogActivity` +- `GetActivityLogs` + +#### `/api/auth` + +- `SendOtp` +- `VerifyOTP` +- `IsAvailable` +- `RegisterUser` +- `UpdateUser` +- `GetUserDetails` +- `LoginUser` +- `RefreshToken` +- `VerifyOtpForLogin` +- `LogOut` + +`POST /api/VerifyOTP` forces `FunctionName = "VerifyOTP"`. + +`POST /api/refreshToken` forces `FunctionName = "RefreshToken"`. + +`POST /api/logout` forces `FunctionName = "LogOut"`. + +#### `/api/client` + +- `GetClientList` +- `GetClientById` +- `GetCustomerDetailsById` +- `CreateCustomerWithPlans` +- `UpdateCustomerByClient` +- `GetCustomersByClientId` +- `GetCustomerPlansByClientId` +- `RegisterClient` +- `GetPackageList` +- `GetAllCustomers` +- `DeleteCustomerByClient` +- `ClientLogOut` +- `GetAllCustomerPlans` +- `GetMealPlansByClientId` +- `GetWorkoutPlansByClientId` +- `GetMealPlanById` +- `GetWorkoutPlanById` +- `GetCustomerPlanById` +- `GetOwnCards` + +#### `/api/staff` + +- `GetAllStaff` +- `GetStaffById` +- `GetStaffByClientId` +- `CreateStaff` +- `UpdateStaff` +- `DeleteStaff` + +#### `/api/clientinvoice` + +- `CreateClientInvoiceFromSubscription` +- `GetClientInvoiceById` +- `GetClientInvoicesByClientId` +- `GetClientInvoicesByUserId` +- `UpdateClientInvoiceStatus` +- `CalculateClientInvoiceTotal` + +#### `/api/customer` + +- `CreateCustomer` +- `DeleteCustomer` +- `UpdateCustomer` +- `GetCustomerMealPlan` +- `GetCustomerMealPlanById` +- `GetCustomerWorkOutPlan` +- `GetCustomerWorkoutPlanById` +- `FollowMealPlan` +- `GetFollowedMealPlans` +- `GetFollowedWorkoutPlans` +- `FollowWorkOutPlan` +- `GetClientDataById` +- `CreateCusHealthRec` +- `CreateCusProgressRec` +- `UpdateCusHealthRec` +- `UpdateCusProgressRec` +- `UpdateCustomerTargetNutritions` +- `GetClientGyms` +- `GetCustomerProgress` +- `UpdateWeeklyProgress` +- `GetWeeklyProgressForCustomer` +- `GetCusBasicDetails` +- `GetCusHealthDetails` +- `GetCusProgressDetails` +- `CreateFeed` +- `GetOwnCards` + +#### `/api/customerinvoice` + +- `CreateCustomerInvoiceFromSubscription` +- `GetCustomerInvoiceById` +- `GetCustomerInvoicesByCustomerId` +- `GetCustomerInvoicesByClientId` +- `UpdateCustomerInvoiceStatus` +- `CalculateCustomerInvoiceTotal` + +#### `/api/customerplan` + +- `GetAllCustomerPlans` +- `GetCustomerPlanById` +- `CreateCustomerPlan` +- `UpdateCustomerPlan` +- `DeleteCustomerPlan` + +#### `/api/customersubscription` + +- `CreateCustomerSubscription` +- `GetCustomerSubscriptionById` +- `GetCustomerSubscriptionsByCustomerId` +- `GetActiveSubscriptionByCustomerId` + +#### `/api/meal` + +- `GetMealCategoryList` +- `CreateMealCategory` +- `GetMealPlanList` +- `GetMealPlanById` +- `GetMealPlanByCustomerId` +- `CreateMealPlan` +- `CreateCustomerMealPlan` +- `UpdateMealPlan` +- `GetMealItemsByMealPlanId` +- `CreateMealItem` +- `DeleteMealPlan` + +#### `/api/workout` + +- `GetWorkoutCategoryList` +- `CreateWorkoutCategory` +- `GetWorkoutPlanList` +- `GetWorkoutPlanById` +- `GetWorkoutPlanByCustomerId` +- `CreateWorkoutPlan` +- `CreateCustomerWorkout` +- `UpdateWorkoutPlan` +- `DeleteWorkoutPlan` +- `GetWorkoutItemsByWorkoutPlanId` +- `CreateWorkoutItem` + +#### `/api/document` + +- `UploadDocument` +- `GetDocumentsByUserId` +- `GetDocumentById` +- `RemoveDocument` + +#### `/api/feed` + +- `GetFeedPostById` +- `GetFeedList` +- `Like` +- `Unlike` +- `IsLiked` +- `CreateFeed` +- `ShowFeedByClientId` +- `DeleteFeedByPostId` + +#### `/api/accessdevice` + +- `AddDevice` +- `RemoveDevice` +- `GeneratePairingCode` +- `AddUser` +- `WhiteListMember` +- `BlackListMember` + +#### `/api/attendance` + +- `GetAllAttendanceRecords` +- `GetAttendanceCounts` +- `GetTodayAttendance` + +#### `/api/chat` + +Current function map in code: + +- `GetOrCreateChatAsync` +- `GetClientById` -> calls `SendMessageAsync` +- `RegisterClient` -> calls `GetMessagesAsync` + +These function names look legacy/inconsistent, but this is the current implementation. + +#### `/api/payment` + +- `CreateCustomerPayment` +- `CreateClientPayment` +- `GetPaymentById` +- `GetPaymentsByCustomerInvoice` +- `GetPaymentsByClientInvoice` +- `ApprovePayment` + +#### `/api/clientstat` + +- `GetTotalCustomerCount` +- `GetMonthlyCustomerCount` +- `GetTotalWorkoutPlanCount` +- `GetTotalMealPlanCount` +- `GetLastMonthCustomerCount` +- `GetLastFiveAttendances` +- `GetLastFivePayments` +- `GetLastMonthRevenue` +- `GetMonthlyRevenueVariation` +- `GetTotalRevenue` +- `GetClientStatSummary` + +#### `/api/register` + +- `CreateCustomer` +- `RegisterClient` + +## B. `AdminController` (`/api/admin`) + +All endpoints are protected by `CustomMiddleware`. + +| Method | Path | Request | Response | +|---|---|---|---| +| `GET` | `/api/admin/onboarding-requests` | query: `OnboardingRequestQuery` | `ApiResponse>` | +| `GET` | `/api/admin/onboarding-requests/{id}` | route `id` | `ApiResponse` | +| `PATCH` | `/api/admin/onboarding-requests/{id}/status` | body: `UpdateOnboardingStatusRequest` | `ApiResponse` | +| `POST` | `/api/admin/onboarding-requests/{id}/resend-activation-link` | route `id` | `ApiResponse` with `{ sent = true }` | +| `GET` | `/api/admin/payout-kyc` | query: `status` | `ApiResponse>` | +| `GET` | `/api/admin/payout-kyc/{clientId}` | route `clientId` | `ApiResponse` | +| `PATCH` | `/api/admin/payout-kyc/{clientId}/status` | body: `ReviewPayoutKycRequest` | `ApiResponse` | +| `GET` | `/api/admin/clients` | query: `ClientDirectoryQuery` | `ApiResponse>` | +| `GET` | `/api/admin/clients/{id}` | route `id` | `ApiResponse` | + +## C. `ClientController` (`/api/client`) + +### C1. Onboarding request access + +| Method | Path | Auth | Request | Response | +|---|---|---|---|---| +| `POST` | `/api/client/onboarding/requests/mobile-check` | none | `MobileNumberCheckRequest` | `ApiResponse` | +| `POST` | `/api/client/onboarding/requests/otp` | none | `SendMobileOtpRequest` | `ApiResponse` | +| `POST` | `/api/client/onboarding/requests/otp/verify` | none | `VerifyMobileOtpRequest` | `ApiResponse` | +| `POST` | `/api/client/onboarding/requests` | bearer request-access token | `CreateOnboardingRequest` | `ApiResponse` | +| `GET` | `/api/client/onboarding/sandbox/{sandboxToken}/status` | none | route `sandboxToken` | `ApiResponse` | +| `PATCH` | `/api/client/onboarding/sandbox/{sandboxToken}/progress` | none | `SandboxProgressUpdate` | `ApiResponse` | + +### C2. Activation + +| Method | Path | Auth | Request | Response | +|---|---|---|---|---| +| `POST` | `/api/client/activation/{activationToken}/email/otp` | none | `SendEmailOtpRequest` | `ApiResponse` | +| `POST` | `/api/client/activation/{activationToken}/email/otp/verify` | none | `VerifyOtpCodeRequest` | `ApiResponse` with `{ emailVerified = true }` | +| `POST` | `/api/client/activation/{activationToken}/nic` | none | multipart form: `nicNumber`, `frontImage`, `backImage` | `ApiResponse` | +| `PUT` | `/api/client/activation/{activationToken}/gym-profile` | none | `GymProfileRequest` | `ApiResponse` | +| `POST` | `/api/client/activation/{activationToken}/payment-method` | none | `SetPaymentMethodRequest` | `ApiResponse` | +| `POST` | `/api/client/activation/{activationToken}/charge` | none | none | `ApiResponse` | +| `GET` | `/api/client/activation/{activationToken}/checklist` | none | route `activationToken` | `ApiResponse>` | +| `GET` | `/api/client/activation/{activationToken}/status` | none | route `activationToken` | `ApiResponse` | + +### C3. Own profile and business + +| Method | Path | Auth | Request | Response | +|---|---|---|---|---| +| `GET` | `/api/client/me` | `CustomMiddleware` | none | `ApiResponse` | +| `GET` | `/api/client/packages` | `CustomMiddleware` | none | `ApiResponse` | + +### C4. Customer management + +| Method | Path | Auth | Request | Response | +|---|---|---|---|---| +| `GET` | `/api/client/customers` | `CustomMiddleware` | none | `ApiResponse` | +| `GET` | `/api/client/customers/{customerId}` | `CustomMiddleware` | route `customerId` | `ApiResponse` | +| `POST` | `/api/client/customers` | `CustomMiddleware` | `CreateCustomerWithPlansRequest` | `ApiResponse` | +| `PUT` | `/api/client/customers/{customerId}` | `CustomMiddleware` | `UpdateCustomerByClientRequest` | `ApiResponse` | +| `DELETE` | `/api/client/customers/{customerId}` | `CustomMiddleware` | query `softDelete=true` optional | `ApiResponse` | + +### C5. Staff management + +| Method | Path | Auth | Request | Response | +|---|---|---|---|---| +| `GET` | `/api/client/staff` | `CustomMiddleware` | none | `ApiResponse` | +| `GET` | `/api/client/staff/{staffId}` | `CustomMiddleware` | route `staffId` | `ApiResponse` | +| `POST` | `/api/client/staff` | `CustomMiddleware` | `CreateStaffRequest` | `ApiResponse` | +| `PUT` | `/api/client/staff/{staffId}` | `CustomMiddleware` | `UpdateStaffRequest` | `ApiResponse` | +| `DELETE` | `/api/client/staff/{staffId}` | `CustomMiddleware` | query `softDelete=true` optional | `ApiResponse` | + +### C6. Invoices + +`customer-invoices` here means platform invoices billed to the gym. + +`member-invoices` means invoices billed to gym members. + +| Method | Path | Auth | Request | Response | +|---|---|---|---|---| +| `GET` | `/api/client/customer-invoices` | `CustomMiddleware` | none | `ApiResponse` | +| `GET` | `/api/client/customer-invoices/{id}` | `CustomMiddleware` | route `id` | `ApiResponse` | +| `GET` | `/api/client/member-invoices` | `CustomMiddleware` | none | `ApiResponse` | +| `GET` | `/api/client/member-invoices/{id}` | `CustomMiddleware` | route `id` | `ApiResponse` | + +### C7. Dashboard stats + +| Method | Path | Auth | Request | Response | +|---|---|---|---|---| +| `GET` | `/api/client/stats/summary` | `CustomMiddleware` | none | `ApiResponse` | +| `GET` | `/api/client/stats/monthly-revenue` | `CustomMiddleware` | none | `ApiResponse` | +| `GET` | `/api/client/stats/recent-payments` | `CustomMiddleware` | none | `ApiResponse` | +| `GET` | `/api/client/stats/recent-attendance` | `CustomMiddleware` | none | `ApiResponse` | + +### C8. Payments enablement + +| Method | Path | Auth | Request | Response | +|---|---|---|---|---| +| `GET` | `/api/client/payments/tracks` | `CustomMiddleware` | none | `ApiResponse>` | +| `POST` | `/api/client/payments/track-selection` | `CustomMiddleware` | `SelectPaymentTrackRequest` | `ApiResponse` | +| `POST` | `/api/client/payments/kyc` | `CustomMiddleware` | multipart form: `brCertificate`, `bankProof` | `ApiResponse` | +| `GET` | `/api/client/payments/kyc/status` | `CustomMiddleware` | none | `ApiResponse` with `{ kycStatus }` | +| `GET` | `/api/client/payments/wallet` | `CustomMiddleware` | none | `ApiResponse` | +| `GET` | `/api/client/payments/payout-schedule` | `CustomMiddleware` | none | `ApiResponse` | + +### C9. Attendance + +| Method | Path | Auth | Request | Response | +|---|---|---|---|---| +| `GET` | `/api/client/attendance` | `CustomMiddleware` | none | `ApiResponse` | +| `GET` | `/api/client/attendance/today` | `CustomMiddleware` | none | `ApiResponse` | +| `GET` | `/api/client/attendance/counts` | `CustomMiddleware` | query `fromDate`, `toDate` | `ApiResponse` | + +### C10. Membership plans + +| Method | Path | Auth | Request | Response | +|---|---|---|---|---| +| `GET` | `/api/client/plans` | `CustomMiddleware` | query `includeInactive=false` | `ApiResponse` | +| `GET` | `/api/client/plans/{id}` | `CustomMiddleware` | route `id` | `ApiResponse` | +| `POST` | `/api/client/plans` | `CustomMiddleware` | `CreateCustomerPlanRequest` | `ApiResponse` | +| `PUT` | `/api/client/plans/{id}` | `CustomMiddleware` | `UpdateCustomerPlanRequest` | `ApiResponse` | +| `DELETE` | `/api/client/plans/{id}` | `CustomMiddleware` | route `id` | `ApiResponse` | + +### C11. Workout plans + +| Method | Path | Auth | Request | Response | +|---|---|---|---|---| +| `GET` | `/api/client/workout-plans` | `CustomMiddleware` | none | `ApiResponse` | +| `GET` | `/api/client/workout-plans/{id}` | `CustomMiddleware` | route `id` | `ApiResponse` | +| `POST` | `/api/client/workout-plans` | `CustomMiddleware` | `WorkoutPlanRequest` | `ApiResponse` | +| `PUT` | `/api/client/workout-plans/{id}` | `CustomMiddleware` | `WorkoutPlanRequest` | `ApiResponse` | +| `DELETE` | `/api/client/workout-plans/{id}` | `CustomMiddleware` | route `id` | `ApiResponse` | +| `GET` | `/api/client/workout-plans/{id}/items` | `CustomMiddleware` | route `id` | `ApiResponse` | +| `POST` | `/api/client/workout-plans/{id}/items` | `CustomMiddleware` | `WorkoutItemRequest` | `ApiResponse` | +| `GET` | `/api/client/workout-plans/categories` | `CustomMiddleware` | none | `ApiResponse` | +| `POST` | `/api/client/workout-plans/categories` | `CustomMiddleware` | `PlanCategoryRequest` | `ApiResponse` | +| `POST` | `/api/client/workout-plans/{id}/assignments` | `CustomMiddleware` | `PlanAssignRequest` | `ApiResponse` | +| `DELETE` | `/api/client/workout-plans/assignments/{customerWorkoutId}` | `CustomMiddleware` | route `customerWorkoutId` | `ApiResponse` | + +### C12. Meal plans + +| Method | Path | Auth | Request | Response | +|---|---|---|---|---| +| `GET` | `/api/client/meal-plans` | `CustomMiddleware` | none | `ApiResponse` | +| `GET` | `/api/client/meal-plans/{id}` | `CustomMiddleware` | route `id` | `ApiResponse` | +| `POST` | `/api/client/meal-plans` | `CustomMiddleware` | `MealPlanRequest` | `ApiResponse` | +| `PUT` | `/api/client/meal-plans/{id}` | `CustomMiddleware` | `MealPlanRequest` | `ApiResponse` | +| `DELETE` | `/api/client/meal-plans/{id}` | `CustomMiddleware` | route `id` | `ApiResponse` | +| `GET` | `/api/client/meal-plans/{id}/items` | `CustomMiddleware` | route `id` | `ApiResponse` | +| `POST` | `/api/client/meal-plans/{id}/items` | `CustomMiddleware` | `MealItemRequest` | `ApiResponse` | +| `GET` | `/api/client/meal-plans/categories` | `CustomMiddleware` | none | `ApiResponse` | +| `POST` | `/api/client/meal-plans/categories` | `CustomMiddleware` | `PlanCategoryRequest` | `ApiResponse` | +| `POST` | `/api/client/meal-plans/{id}/assignments` | `CustomMiddleware` | `PlanAssignRequest` | `ApiResponse` | +| `DELETE` | `/api/client/meal-plans/assignments/{customerMealId}` | `CustomMiddleware` | route `customerMealId` | `ApiResponse` | + +### C13. Feed + +| Method | Path | Auth | Request | Response | +|---|---|---|---|---| +| `GET` | `/api/client/feed` | `CustomMiddleware` | none | `ApiResponse` | +| `POST` | `/api/client/feed` | `CustomMiddleware` | `CreateFeedRequest` | `ApiResponse` | +| `DELETE` | `/api/client/feed/{postId}` | `CustomMiddleware` | route `postId` | `ApiResponse` | +| `POST` | `/api/client/feed/{postId}/like` | `CustomMiddleware` | route `postId` | `ApiResponse` | +| `DELETE` | `/api/client/feed/{postId}/like` | `CustomMiddleware` | route `postId` | `ApiResponse` | +| `GET` | `/api/client/feed/{postId}/like` | `CustomMiddleware` | route `postId` | `ApiResponse` | + +### C14. Tickets + +| Method | Path | Auth | Request | Response | +|---|---|---|---|---| +| `GET` | `/api/client/tickets` | `CustomMiddleware` | none | `ApiResponse` | +| `GET` | `/api/client/tickets/{id}` | `CustomMiddleware` | route `id` | `ApiResponse` | +| `GET` | `/api/client/tickets/categories` | `CustomMiddleware` | none | `ApiResponse` | +| `POST` | `/api/client/tickets` | `CustomMiddleware` | `CreateTicketRequest` | `ApiResponse` | +| `PUT` | `/api/client/tickets/{id}` | `CustomMiddleware` | `UpdateTicketRequest` | `ApiResponse` | +| `POST` | `/api/client/tickets/{id}/messages` | `CustomMiddleware` | `SendTicketMessageRequest` | `ApiResponse` | +| `POST` | `/api/client/tickets/{id}/feedback` | `CustomMiddleware` | `TicketFeedbackRequest` | `ApiResponse` | +| `POST` | `/api/client/tickets/{id}/close` | `CustomMiddleware` | none | `ApiResponse` | +| `DELETE` | `/api/client/tickets/{id}` | `CustomMiddleware` | route `id` | `ApiResponse` | + +### C15. Subscriptions and group members + +| Method | Path | Auth | Request | Response | +|---|---|---|---|---| +| `GET` | `/api/client/subscriptions/{id}` | `CustomMiddleware` | route `id` | `ApiResponse` | +| `POST` | `/api/client/subscriptions/{id}/members` | `CustomMiddleware` | `AddSubscriptionMemberRequest` | `ApiResponse` | +| `DELETE` | `/api/client/subscriptions/{id}/members/{memberId}` | `CustomMiddleware` | route `id`, route `memberId` | `ApiResponse` | +| `GET` | `/api/client/subscriptions/{id}/payments` | `CustomMiddleware` | route `id` | `ApiResponse` | +| `POST` | `/api/client/subscriptions/{id}/payments` | `CustomMiddleware` | `RecordSubscriptionPaymentRequest` | `ApiResponse` | + +## D. `AgentController` (`/api/agent`) + +| Method | Path | Auth | Request | Response | +|---|---|---|---|---| +| `POST` | `/api/agent/enroll` | anonymous | `EnrollRequest` | `ApiResponse` | +| `POST` | `/api/agent/devices` | `X-Api-Key` | `RegisterDeviceRequest` | `ApiResponse` | +| `POST` | `/api/agent/attendance` | `X-Api-Key` | `AgentAttendanceRequest` | `ApiResponse` | +| `GET` | `/api/agent/commands` | `X-Api-Key` | none | `ApiResponse` | +| `POST` | `/api/agent/commands/{id}/result` | `X-Api-Key` | `CommandResultRequest` | `ApiResponse` | +| `POST` | `/api/agent/heartbeat` | `X-Api-Key` | none | `ApiResponse` | + +## Request and Response DTOs + +## Onboarding and activation DTOs + +Defined in `Models/IOs/OnboardingDtos.cs`. + +### Request contracts + +- `SendMobileOtpRequest` + - `mobileNumber` +- `VerifyMobileOtpRequest` + - `mobileNumber` + - `referenceNumber` + - `otpCode` +- `VerifyOtpCodeRequest` + - `referenceNumber` + - `otpCode` +- `CreateOnboardingRequest` + - `ownerName` + - `gymName` + - `city` +- `MobileNumberCheckRequest` + - `mobileNumber` +- `SandboxProgressUpdate` + - `tourStepCompleted` + - `minutesElapsedDelta` +- `SendEmailOtpRequest` + - `email` +- `GymProfileRequest` + - `gymName` + - `address` + - `city` +- `SetPaymentMethodRequest` + - `payHereToken` +- `UpdateOnboardingStatusRequest` + - `status` + - `reason` +- `ReviewPayoutKycRequest` + - `status` + - `reason` +- `SelectPaymentTrackRequest` + - `track` +- `OnboardingRequestQuery` + - `status` + - `search` + - `page` + - `pageSize` +- `ClientDirectoryQuery` + - `search` + - `page` + - `pageSize` + +### Response contracts + +- `SendOtpResult` + - `referenceNumber` + - `expiresInSeconds` + - `resendAllowedInSeconds` +- `VerifyOtpResult` + - `requestAccessToken` + - `expiresIn` +- `OnboardingRequestCreated` + - `onboardingRequestId` + - `sandboxToken` + - `status` +- `SandboxStatus` + - `id` + - `status` + - `bannerMessage` + - `tourProgress` + - `minutesInSandbox` +- `ActivationStatusResult` + - `status` + - `canActivate` + - `rejectionReason` +- `MobileNumberCheckResult` + - `exists` + - `message` + - `status` + - `sandboxToken` +- `OnboardingRequestSummary` +- `OnboardingRequestDetail` +- `PagedResult` +- `NicUploadResult` + - `nicFrontDocId` + - `nicBackDocId` +- `OwnerActivationSummary` +- `PaymentMethodResult` + - `cardLast4` + - `cardType` +- `ActivationCompleteResult` + - `accessToken` + - `refreshToken` + - `clientId` + - `checklist` +- `SetupChecklistItem` + - `key` + - `label` + - `isComplete` +- `PaymentTrackOption` + - `track` + - `feeSummary` +- `PaymentTrackSelectionResult` + - `track` + - `kycRequired` +- `ClientPayoutKycDto` + - `id` + - `clientId` + - `kycStatus` + - `submittedAt` +- `WalletDto` +- `WalletLedgerEntryDto` +- `PayoutScheduleDto` +- `ClientSummary` + +## ClientController inline request DTOs + +These are declared at the bottom of `Controllers/ClientController.cs`. + +- `WorkoutPlanRequest` + - `name` + - `description` + - `isTemplate` + - `workoutCategoryId` + - `customerIds` + - `workoutItems` +- `WorkoutItemRequest` + - `exerciseDay` + - `description` + - `exercises` + - `howToDo` + - `doURL` +- `MealPlanRequest` + - `name` + - `description` + - `isTemplate` + - `mealCategoryId` + - `customerIds` + - `mealItems` +- `MealItemRequest` + - `name` + - `description` + - `calories` + - `servingSize` + - `preparationNotes` +- `PlanCategoryRequest` + - `name` + - `description` +- `PlanAssignRequest` + - `customerId` + - `notes` +- `CreateFeedRequest` + - `caption` + - `description` + - `document` +- `CreateTicketRequest` + - `mainReason` + - `ticketCategoryId` +- `UpdateTicketRequest` + - `mainReason` + - `ticketCategoryId` +- `SendTicketMessageRequest` + - `message` + - `attachmentURL` +- `TicketFeedbackRequest` + - `rating` + - `feedback` +- `AddSubscriptionMemberRequest` + - `customerId` +- `RecordSubscriptionPaymentRequest` + - `amount` + - `note` + +## DTO inventory under `DTOs/` + +### Auth + +- `DTOs/Auth/AuthResponse.cs` + - `AuthResponse` + +### Agent + +- `DTOs/Agent/AgentRequests.cs` + - `EnrollRequest` + - `RegisterDeviceRequest` + - `AgentAttendanceRequest` + - `AgentAttendanceEvent` + - `CommandResultRequest` + +### Attendance + +- `AttendanceCountDTO` +- `AttendanceDTO` +- `TodayAttendanceDTO` + +### Client invoice + +- `ClientInvoiceDto` +- `ClientInvoiceItemDto` +- `CreateClientInvoiceRequest` +- `UpdateClientInvoiceStatusRequest` + +### Customer + +- `CreateCustomerRequest` +- `CustomerDto` +- `MemberListRowDto` +- `UpdateCustomerRequest` + +### Customer invoice + +- `CreateCustomerInvoiceRequest` +- `CustomerInvoiceDto` +- `CustomerInvoiceItemDto` +- `UpdateCustomerInvoiceStatusRequest` + +### Customer plan + +- `CreateCustomerPlanRequest` +- `CustomerPlanDto` +- `UpdateCustomerPlanRequest` + +### Customer subscription + +- `CreateCustomerSubscriptionRequest` +- `CustomerSubscriptionDto` +- `CustomerSubItemDto` +- `SubscriptionSummaryDto` +- `SubscriptionMemberDto` +- `SubscriptionInstallmentDto` + +### Meal + +- `CustomerMealDto` +- `MealCategoryDto` +- `MealDto` +- `MealPlanDetailDto` +- `MealPlanDto` + +### Staff + +- `CreateStaffRequest` +- `StaffDto` +- `UpdateStaffRequest` + +### Stats + +- `ClientStatSummaryDto` +- `MonthlyRevenueStat` + +### Ticket + +- `TicketDto` +- `TicketDetailDto` +- `TicketMessageDto` + +### Workout + +- `CustomerWorkoutDto` +- `WorkoutCategoryDto` +- `WorkoutItemDto` +- `WorkoutPlanDetailDto` +- `WorkoutPlanDto` + +### Miscellaneous + +- `DocumentDownloadModel` +- `UpdateTuteItemRequest` + +## Notable request DTO field detail + +### `CreateCustomerPlanRequest` + +Fields: + +- `planName` required +- `planDescription` optional +- `planPrice` required decimal +- `durationInDays` optional, validated by service +- `planScope` optional, `"Individual"` or `"Group"` +- `memberLimit` optional, used for group plans +- `durationMode` optional, `"Rolling"` or `"FixedRange"` +- `validFrom` optional +- `validTo` optional +- `allowSplitPayments` optional +- `installmentCount` optional + +### `AuthResponse` + +Fields: + +- `statusCode` +- `success` +- `message` +- `data` + +## Helpers + +### `Helpers/ApiKeyHelper.cs` + +- generates 256-bit agent API keys +- hashes API keys using SHA-256 for storage and lookup + +### `Helpers/ApiResponseHelper.cs` + +- wraps data into `ApiResponse` + +### `Helpers/AuthOtpResponseParser.cs` + +- parses OTP-related auth responses + +### `Helpers/CustomerProgressHelper.cs` + +- customer fitness/progress calculations and predictions +- related to progress formulas in `CustomerProgressFormulas.txt` + +### `Helpers/DataExtractor.cs` + +- extracts typed values from JSON payload dictionaries +- helpers for string, int, decimal, bool, guid, datetime, date, and auth response parsing +- provides `GetUserIdFromContext` + +### `Helpers/DeserializePayload.cs` + +- payload deserialization helper + +### `Helpers/EncryptionHelper.cs` + +- encryption-related utility methods + +### `Helpers/JwtMiddleware.cs` + +- custom auth filter named `CustomMiddlewareAttribute` +- validates standard JWT access tokens + +### `Helpers/RequestAccessTokenHelper.cs` + +- issues and validates onboarding request-access tokens + +### `Helpers/UserRoleHelper.cs` + +- resolves role/client relationships from users + +## Services + +### Core services + +- `AccessDeviceService` +- `ActivationService` +- `ActivityLogService` +- `AdminClientDirectoryService` +- `AgentService` +- `AttendanceService` +- `AuthService` +- `ChatService` +- `ClientInvoiceService` +- `ClientService` +- `ClientStatService` +- `CustomerInvoiceService` +- `CustomerPlanService` +- `CustomerService` +- `CustomerSubscriptionService` +- `DocumentService` +- `FeedService` +- `MealService` +- `OnboardingAdminService` +- `OnboardingService` +- `PaymentService` +- `PaymentsEnablementService` +- `PayoutKycReviewService` +- `SmsService` +- `StaffService` +- `TicketService` +- `WorkoutService` + +### Function handler services + +- `AccessDeviceFunctionHandler` +- `ActivityLogFuncHandler` +- `AttendanceFunctionHandler` +- `AuthFuncHandler` +- `ChatFunctionHandler` +- `ClientFuncHandler` +- `ClientInvoiceFunctionHandler` +- `ClientStatFuncHandler` +- `CustomerFunctionHandler` +- `CustomerInvoiceFunctionHandler` +- `CustomerPlanFunctionHandler` +- `CustomerSubscriptionFunctionHandler` +- `DocumentFunctionHandler` +- `FeedFuncHandler` +- `MealFunctionHandler` +- `PaymentFunctionHandler` +- `StaffFunctionHandler` +- `TicketFunctionhandler` +- `UserRegisterHandler` +- `WorkoutFunctionHandler` + +### AI services + +- `GeminiClient` +- `IAiClient` +- `MealPlanOrchestrator` +- `WorkoutPlanOrchestrator` +- `ModificationGuard` + +## Repositories + +- `AccessDeviceRepo` +- `ActivityLogRepository` +- `AdminClientDirectoryRepository` +- `AgentRepo` +- `AttendanceRepository` +- `ChatRepository` +- `ClientInvoiceRepository` +- `ClientRepository` +- `ClientStatRepository` +- `CustomerInvoiceRepository` +- `CustomerPlanRepository` +- `CustomerRepository` +- `CustomerSubscriptionRepository` +- `DeviceCommandRepo` +- `DocumentRepository` +- `FeedRepository` +- `MealRepository` +- `OnboardingRepository` +- `PairingCodeRepo` +- `PaymentRepository` +- `PaymentsEnablementRepository` +- `StaffRepository` +- `TicketRepository` +- `WorkoutRepository` + +## Data Model Inventory + +### Main entities + +- `User` +- `Admin` +- `Client` +- `ClientBusiness` +- `ClientCard` +- `ClientPackage` +- `ClientSubscription` +- `ClientSubItems` +- `ClientInvoice` +- `ClientInvoiceItem` +- `ClientPayoutKyc` +- `ClientWallet` +- `WalletLedgerEntry` +- `PayoutBatch` + +- `Customer` +- `CustomerAccess` +- `CustomerCards` +- `CustomerHealth` +- `CustomerTarget` +- `CusWeightLossProgress` +- `CustomerPlan` +- `CustomerSubscription` +- `CustomerSubItem` +- `SubscriptionMember` +- `SubscriptionInstallment` +- `CustomerInvoice` +- `CustomerInvoiceItem` +- `CustomerWorkout` +- `CustomerMeal` + +- `WorkoutCategory` +- `WorkoutPlan` +- `WorkoutItem` + +- `MealCategory` +- `MealPlan` +- `Meal` + +- `FeedPost` +- `FeedLike` + +- `Ticket` +- `TicketCategory` +- `TicketMessage` +- `TicketStatus` + +- `Attendance` +- `ActivityLog` + +- `Chat` +- `ChatMessages` + +- `UserDocument` +- `DocumentTypes` + +- `AccessDevice` +- `Agent` +- `PairingCode` +- `DeviceCommand` + +- `OnboardingRequest` +- `OwnerActivation` + +### Enum inventory + +- `BeneficiaryType` +- `BusinessType` +- `FirstChargeStatus` +- `KycStatus` +- `OnboardingStatus` +- `PaymentTrack` +- `PayoutBatchStatus` +- `PriorityLevel` +- `ProgressGoalType` +- `Sex` +- `TicketStatusEnum` +- `WalletEntryType` + +### EF Core DbSets in `AppDBContext` + +The context currently exposes DbSets for all major entities above, including: + +- users, clients, customers, staff +- plans and subscriptions +- invoices and payments +- meals and workouts +- feed, chat, tickets, attendance +- access devices and agents +- onboarding and payout flows + +## Interfaces + +### Repository interfaces + +- `IActivityLogRepository` +- `IAccessDeviceRepo` +- `IAdminClientDirectoryRepository` +- `IAgentRepo` +- `IAttendanceRepository` +- `IChatRepo` +- `IClientInvoiceRepository` +- `IClientRepository` +- `IClientStatRepository` +- `ICustomerInvoiceRepository` +- `ICustomerPlanRepository` +- `ICustomerRepository` +- `ICustomerSubscriptionRepository` +- `IDeviceCommandRepo` +- `IDocumentRepository` +- `IFeedRepository` +- `IMealRepository` +- `IOnboardingRepository` +- `IPairingCodeRepo` +- `IPaymentRepository` +- `IPaymentsEnablementRepository` +- `IStaffRepository` +- `ITicketRepository` +- `IWorkoutRepository` + +### Service interfaces + +- `IAccessDeviceService` +- `IActivationService` +- `IActivityLogService` +- `IAdminClientDirectoryService` +- `IAgentService` +- `IAttendanceService` +- `IAuthService` +- `IChatService` +- `IClientInvoiceService` +- `IClientService` +- `IClientStatService` +- `ICustomerInvoiceService` +- `ICustomerPlanService` +- `ICustomerService` +- `ICustomerSubscriptionService` +- `IDocumentService` +- `IFeedService` +- `IMealService` +- `IOnboardingAdminService` +- `IOnboardingService` +- `IPaymentService` +- `IPaymentsEnablementService` +- `IPayoutKycReviewService` +- `ISmsService` +- `IStaffService` +- `ITicketService` +- `IWorkoutService` + +## Other Important Files + +### Markdown + +- `README.md` + - current placeholder text only +- `PROJECT_REFERENCE.md` + - this generated project reference + +### Postman + +- `Fitness_Core_API.postman_collection.json` +- `postman/Client-Admin-API.postman_collection.json` + +### Miscellaneous + +- `CustomerProgressFormulas.txt` + - formulas used by customer progress logic +- `tools/Update-Md5Reference.ps1` + - PowerShell utility script + +## Practical Notes and Caveats + +1. Many newer endpoints in `ClientController` and `AdminController` still return `ApiResponse`. + The actual data shape comes from service-layer return objects and may not always have a dedicated DTO class. + +2. Some REST endpoints intentionally omit null properties before passing payloads to services. + This matters because several services use direct `GetString()`, `GetDecimal()`, or similar access patterns. + +3. The legacy RPC layer is still a major part of the system. + If you are building a frontend or integration, do not assume every domain has a clean REST endpoint yet. + +4. Chat function names in `ChatFunctionHandler` appear mismatched with the actual service methods. + This document lists them exactly as implemented. + +5. Protected endpoints mostly rely on the custom middleware rather than standard `[Authorize]` attributes. + +## Suggested Reading Order + +If you need to understand the project quickly, read in this order: + +1. `Program.cs` +2. `Controllers/ClientController.cs` +3. `Controllers/AdminController.cs` +4. `Controllers/AgentController.cs` +5. `Controllers/APIController.cs` +6. `Models/IOs/OnboardingDtos.cs` +7. `Helpers/JwtMiddleware.cs` +8. `Services/FunctionHandler/*` +9. target service file for the domain you are changing + diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..3b716a0 --- /dev/null +++ b/Program.cs @@ -0,0 +1,298 @@ +using Fitness_core_BE; +using Fitness_core_BE.AI; +using Fitness_core_BE.Auth; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Hubs; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Repos; +using Fitness_core_BE.Services; +using Fitness_core_BE.Services.ActivityLog; +using Fitness_core_BE.Services.Auth; +using Fitness_core_BE.Services.Chat; +using Fitness_core_BE.Services.ClientInvoice; +using Fitness_core_BE.Services.CustomerService; +using Fitness_core_BE.Services.CustomerInvoice; +using Fitness_core_BE.Services.Payment; +using Fitness_core_BE.Services.CustomerPlanService; +using Fitness_core_BE.Services.CustomerSubscription; +using Fitness_core_BE.Services.Document; +using Fitness_core_BE.Services.Feed; +using Fitness_core_BE.Services.FunctionHandler; +using Fitness_core_BE.Services.Meal; +using Fitness_core_BE.Services.Staff; +using Fitness_core_BE.Services.Ticket; +using Fitness_core_BE.Services.Workout; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + + +QuestPDF.Settings.License = QuestPDF.Infrastructure.LicenseType.Community; + +// Add services to the container. + +builder.Services.AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles; + }); +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo + { + Title = "Fitness Core Backend API", + Version = "v1", + Description = "API for managing fitness-related operations including ticket management system", + Contact = new Microsoft.OpenApi.Models.OpenApiContact + { + Name = "Development Team", + Email = "dev@fitness-core.com" + } + }); + + // Enable XML comments if available + var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var xmlPath = System.IO.Path.Combine(AppContext.BaseDirectory, xmlFile); + if (System.IO.File.Exists(xmlPath)) + { + options.IncludeXmlComments(xmlPath); + } +}); + + + +// AppDBContext is kept as inert legacy code (not deleted, per the non-destructive migration +// principle) but is intentionally left unregistered -- CentralDbContext/TenantDbContext below +// are what every repo/service now actually uses. + +builder.Services.AddHttpContextAccessor(); + +// Single club, single database -- both contexts share one connection string. (GYM's split +// used CentralDbContext/TenantDbContext to route each request to one of many per-client MySQL +// schemas; Vijaya has exactly one club, so that routing layer is gone and both contexts just +// point at the same DB. The two C# context classes stay separate to avoid a much larger, +// riskier refactor across every repo that injects one or the other.) +builder.Services.AddDbContext(options => +{ + options.UseMySql( + builder.Configuration.GetConnectionString("DefaultConnection"), + // Keep startup and EF tooling independent of database network availability. + // This is the minimum supported MySQL baseline; Pomelo adjusts generated SQL. + new MySqlServerVersion(new Version(8, 0, 0)) + ); +}); + +builder.Services.AddDbContext(options => +{ + options.UseMySql( + builder.Configuration.GetConnectionString("DefaultConnection"), + new MySqlServerVersion(new Version(8, 0, 0)) + ); +}); + +builder.Services.AddCors(o => + o.AddPolicy("AllowAll", p => + p.SetIsOriginAllowed(origin => true) + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials() + ) +); + + +builder.Services.AddHttpClient(); + +builder.Services.AddScoped(); + +builder.Services.AddSignalR(); + +builder.Services.AddHttpContextAccessor(); + +// Register Repositories +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Register Helpers +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Register Services +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +// Register Function Handlers +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Register AI Services +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddHttpClient(); + +// Register Access Device Service +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Register Payment-Service clients: catalog/provisioning admin proxy (X-Internal-Key), +// and the per-platform HMAC-signed client for real card/payment/refund calls. +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Shared by background jobs that have no HttpContext to resolve a tenant from -- lets them +// loop over every real, provisioned tenant schema directly instead of only ever seeing +// whichever schema TenantConnectionResolver's request-based fallback resolves to. +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// Auto-lock members after configurable days overdue (req 7) -- disabled at the user's request. +// OverdueLockRunner (above) stays registered, so the manual "trigger now" endpoint +// (POST api/admin/background-jobs/overdue-lock/trigger) still works on demand; only the +// automatic startup + 6-hourly run is turned off. Uncomment to re-enable. +// builder.Services.AddHostedService(); + +// Auto-renew expired subscriptions (new plan price + carried-over overdue balance) +builder.Services.AddHostedService(); + +// Basic P&L / ledger for the client portal +builder.Services.AddScoped(); + +// Register On-prem Agent platform +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Agent API-key authentication scheme + policy (does not affect existing admin/user auth). +builder.Services + .AddAuthentication() + .AddScheme( + AgentApiKeyDefaults.AuthenticationScheme, _ => { }) + .AddScheme( + UserJwtDefaults.AuthenticationScheme, _ => { }); + +builder.Services.AddAuthorization(options => +{ + options.AddPolicy(AgentApiKeyDefaults.PolicyName, policy => + { + policy.AddAuthenticationSchemes(AgentApiKeyDefaults.AuthenticationScheme); + policy.RequireAuthenticatedUser(); + }); +}); + +// Register Attendance Service +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +var app = builder.Build(); +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +// First in the pipeline so it catches anything thrown by everything after it. +app.UseMiddleware(); + +app.UseRouting(); + +app.UseCors("AllowAll"); + +app.UseAuthentication(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.MapHub("/hubs/chat"); +app.MapHub("/hubs/access"); + +app.Run(); diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..04e36a0 --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:47780", + "sslPort": 44395 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5044", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7152;http://localhost:5044", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Repos/AccessDeviceRepo.cs b/Repos/AccessDeviceRepo.cs new file mode 100644 index 0000000..6f6115b --- /dev/null +++ b/Repos/AccessDeviceRepo.cs @@ -0,0 +1,487 @@ +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class AccessDeviceRepo : IAccessDeviceRepo + { + private readonly TenantDbContext _dbContext; + private readonly CentralDbContext _central; + + public AccessDeviceRepo(TenantDbContext dbContext, CentralDbContext central) + { + _dbContext = dbContext; + _central = central; + } + + public async Task AddDeviceAsync(AccessDevice device) + { + var result = await _dbContext.AddAsync(device); + return result; + } + + public async Task RemoveDeviceAsync(Guid clientId) + { + AccessDevice? device = await _dbContext.AccessDevices + .Where(ad => ad.FKClientId == clientId) + .FirstOrDefaultAsync(); + + if (device == null) + throw new NotFoundException($"{clientId} don't have a Device in the System"); + + device.IsActive = false; + + _dbContext.Update(device); + + return device; + + } + + public async Task GetDeviceByDId(Guid deviceId) + { + AccessDevice? device = await _dbContext.AccessDevices + .Where(d => d.DeviceId == deviceId && d.IsActive == true) + .FirstOrDefaultAsync(); + + if (device == null) + throw new NotFoundException($"{deviceId} is not found"); + + return device; + } + + public Task GetDeviceByIdAsync( + Guid deviceId, + CancellationToken ct = default) + { + return _dbContext.AccessDevices + .FirstOrDefaultAsync(device => device.DeviceId == deviceId, ct); + } + + public Task GetDeviceByHardwareIdAsync( + Guid clientId, + string hardwareId, + CancellationToken ct = default) + { + return _dbContext.AccessDevices.FirstOrDefaultAsync(device => + device.FKClientId == clientId && + device.HardwareId == hardwareId, ct); + } + + public Task GetActiveDeviceForClientAsync( + Guid clientId, + Guid deviceId, + CancellationToken ct = default) + { + return _dbContext.AccessDevices.FirstOrDefaultAsync(device => + device.DeviceId == deviceId && + device.FKClientId == clientId && + device.IsActive, ct); + } + + public Task> GetAgentDevicesAsync( + Guid clientId, + Guid agentId, + IReadOnlyCollection deviceIds, + CancellationToken ct = default) + { + return _dbContext.AccessDevices + .Where(device => + device.FKClientId == clientId && + device.FKAgentId == agentId && + deviceIds.Contains(device.DeviceId)) + .ToListAsync(ct); + } + public async Task GetDeviceByCId(Guid clientId) + { + AccessDevice? device = await _dbContext.AccessDevices + .Where(d => d.FKClientId == clientId && d.IsActive == true) + .FirstOrDefaultAsync(); + + if (device == null) + throw new NotFoundException($"{clientId} don't have a device in the system"); + + return device; + } + + public async Task AddCustomerAccessAsync(CustomerAccess customerAccess) + { + await _dbContext.CustomerAccess.AddAsync(customerAccess); + return customerAccess; + } + + public async Task GetByIdAsync(int deviceUserId) + { + CustomerAccess? record = await _dbContext.CustomerAccess + .FindAsync(deviceUserId); + + if (record == null) + throw new NotFoundException($"{deviceUserId} cannot find in the system"); + return record; + } + // Maps a device's employeeNo (== CustomerAccess.Id) back to its CustomerAccess, + // scoped to the device so the same employeeNo on a different panel can't collide. + // Returns null (rather than throwing) when there is no mapping for this device user. + public async Task GetByDeviceAndUserIdAsync(Guid deviceId, int deviceUserId) + { + return await _dbContext.CustomerAccess + .FirstOrDefaultAsync(c => c.FKDeviceId == deviceId && c.Id == deviceUserId); + } + + public async Task GetByCusGuidAsync(Guid CusId) + { + // No IsActive filter here: this is the lookup WhiteListMember/BlackListMember use + // to find the record whose IsActive flag they're about to flip. Filtering on + // IsActive == true made it impossible to ever find (and re-activate) a customer + // who had already been locked, since locking sets IsActive = false. + CustomerAccess? record = await _dbContext.CustomerAccess + .Where(c => c.FKCustomerId == CusId) + .FirstOrDefaultAsync(); + + if (record == null) + throw new NotFoundException($"record cannot find in the system"); + return record; + } + + public async Task UpdateAsync(CustomerAccess customerAccess) + { + _dbContext.CustomerAccess.Update(customerAccess); + return customerAccess; + } + + public async Task> GetDevicesByClientAsync(Guid clientId) + { + return await _dbContext.AccessDevices + .AsNoTracking() + .Where(d => d.FKClientId == clientId && d.IsActive == true) + .ToListAsync(); + } + + public async Task DeviceBelongsToClientAsync(Guid deviceId, Guid clientId) + { + return await _dbContext.AccessDevices + .AnyAsync(d => d.DeviceId == deviceId && d.FKClientId == clientId); + } + + public Task GetCustomerByIdAsync( + Guid customerId, + CancellationToken ct = default) + { + return _central.Customer + .FirstOrDefaultAsync(customer => customer.CusId == customerId, ct); + } + + public async Task GetStaffByIdAsync( + Guid clientId, + Guid staffId, + CancellationToken ct = default) + { + var staff = await _dbContext.Staff + .AsNoTracking() + .FirstOrDefaultAsync(s => + s.StaffId == staffId && + s.FKClientId == clientId, ct); + if (staff == null) + return null; + + // Staff.User is central-bucket -- attach it in memory so callers reading + // staff.User.* still see the right data. + staff.User = await _central.Users.AsNoTracking().FirstOrDefaultAsync(u => u.UserId == staff.FKUserId, ct); + return staff; + } + + // Access on a shared plan is all-or-nothing: the subscription is the unit that was + // paid for, so locking one person on a couple/group plan has to take their co-members + // with it, or the locked member simply walks in on their partner's fingerprint. + public async Task> GetCoMembersOnSharedSubscriptionsAsync( + Guid clientId, + Guid customerId, + CancellationToken ct = default) + { + var sharedSubscriptionIds = await _dbContext.SubscriptionMembers + .AsNoTracking() + .Where(member => + member.FKCustomerId == customerId && + member.IsActive && + member.Subscription.IsActive && + member.Subscription.CustomerPlan.FKClientId == clientId && + member.Subscription.CustomerPlan.PlanScope == "Group") + .Select(member => member.FKSubscriptionId) + .Distinct() + .ToListAsync(ct); + + if (sharedSubscriptionIds.Count == 0) + return new List(); + + return await _dbContext.SubscriptionMembers + .AsNoTracking() + .Where(member => + sharedSubscriptionIds.Contains(member.FKSubscriptionId) && + member.FKCustomerId != customerId && + member.IsActive) + .Select(member => member.FKCustomerId) + .Distinct() + .ToListAsync(ct); + } + + public Task IsActiveCustomerMemberAsync( + Guid clientId, + Guid customerId, + CancellationToken ct = default) + { + return _dbContext.SubscriptionMembers.AnyAsync(member => + member.FKCustomerId == customerId && + member.IsActive && + member.Subscription.IsActive && + member.Subscription.CustomerPlan.FKClientId == clientId, ct); + } + + public async Task GetLatestActiveClientIdForCustomerAsync( + Guid customerId, + CancellationToken ct = default) + { + var clientId = await _dbContext.SubscriptionMembers + .Where(member => + member.FKCustomerId == customerId && + member.IsActive && + member.Subscription.IsActive) + .OrderByDescending(member => member.Subscription.OnboardDate) + .Select(member => member.Subscription.CustomerPlan.FKClientId) + .FirstOrDefaultAsync(ct); + return clientId == Guid.Empty ? null : clientId; + } + + public Task GetAccessProfileAsync( + Guid clientId, + Guid userId, + CancellationToken ct = default) + { + return _dbContext.AccessProfiles + .Include(profile => profile.DeviceBindings) + .FirstOrDefaultAsync(profile => + profile.FKClientId == clientId && + profile.FKUserId == userId, ct); + } + + public Task GetAccessStatusProfileAsync( + Guid clientId, + Guid userId, + CancellationToken ct = default) + { + return _dbContext.AccessProfiles + .AsNoTracking() + .Include(profile => profile.DeviceBindings) + .ThenInclude(binding => binding.AccessDevice) + .FirstOrDefaultAsync(profile => + profile.FKClientId == clientId && + profile.FKUserId == userId, ct); + } + + public Task> GetAccessProfilesByClientAsync( + Guid clientId, + CancellationToken ct = default) + { + return _dbContext.AccessProfiles + .Include(profile => profile.DeviceBindings) + .Where(profile => profile.FKClientId == clientId) + .ToListAsync(ct); + } + + public async Task AddAccessProfileAsync( + AccessProfile profile, + CancellationToken ct = default) + { + await _dbContext.AccessProfiles.AddAsync(profile, ct); + } + + public Task GetBindingAsync( + long accessProfileId, + Guid deviceId, + CancellationToken ct = default) + { + return _dbContext.AccessDeviceBindings.FirstOrDefaultAsync(binding => + binding.FKAccessProfileId == accessProfileId && + binding.FKDeviceId == deviceId, ct); + } + + public Task GetBindingByEmployeeNoAsync( + Guid deviceId, + string employeeNo, + CancellationToken ct = default) + { + return _dbContext.AccessDeviceBindings + .Include(binding => binding.AccessProfile) + .FirstOrDefaultAsync(binding => + binding.FKDeviceId == deviceId && + binding.DeviceEmployeeNo == employeeNo, ct); + } + + public Task> GetBindingsForDeviceAsync( + Guid deviceId, + CancellationToken ct = default) + { + return _dbContext.AccessDeviceBindings + .Include(binding => binding.AccessProfile) + .Where(binding => binding.FKDeviceId == deviceId) + .ToListAsync(ct); + } + + public async Task AddBindingAsync( + AccessDeviceBinding binding, + CancellationToken ct = default) + { + await _dbContext.AccessDeviceBindings.AddAsync(binding, ct); + } + + public Task IsDeviceEmployeeNoOccupiedAsync( + Guid deviceId, + string employeeNo, + Guid exceptUserId, + CancellationToken ct = default) + { + return _dbContext.AccessDeviceBindings.AnyAsync(binding => + binding.FKDeviceId == deviceId && + binding.DeviceEmployeeNo == employeeNo && + binding.AccessProfile.FKUserId != exceptUserId, ct); + } + + public Task> GetDuplicateUnlinkedEnrollmentsAsync( + Guid deviceId, + string employeeNo, + int deviceUserId, + CancellationToken ct = default) + { + return _dbContext.UnlinkedDeviceEnrollments + .Where(enrollment => + enrollment.FKDeviceId == deviceId && + enrollment.LinkedAt == null && + (enrollment.DeviceEmployeeNo == employeeNo || + (enrollment.DeviceEmployeeNo == "" && + enrollment.DeviceUserId == deviceUserId))) + .ToListAsync(ct); + } + + public Task GetUnlinkedEnrollmentByEmployeeNoAsync( + Guid deviceId, + string employeeNo, + CancellationToken ct = default) + { + var tracked = _dbContext.UnlinkedDeviceEnrollments.Local + .FirstOrDefault(enrollment => + enrollment.FKDeviceId == deviceId && + enrollment.DeviceEmployeeNo == employeeNo && + enrollment.LinkedAt == null); + + return tracked != null + ? Task.FromResult(tracked) + : _dbContext.UnlinkedDeviceEnrollments.FirstOrDefaultAsync(enrollment => + enrollment.FKDeviceId == deviceId && + enrollment.DeviceEmployeeNo == employeeNo && + enrollment.LinkedAt == null, ct); + } + + public async Task AddUnlinkedEnrollmentAsync( + UnlinkedDeviceEnrollment enrollment, + CancellationToken ct = default) + { + await _dbContext.UnlinkedDeviceEnrollments.AddAsync(enrollment, ct); + } + + public async Task GetCustomerIdForUserAtClientAsync( + Guid userId, + Guid clientId, + CancellationToken ct = default) + { + // Central lookup for the customer identity, then a tenant-scoped membership + // check -- these used to be a single cross-table query before the split. + var cusId = await _central.Customer + .Where(customer => customer.FKUserId == userId) + .Select(customer => (Guid?)customer.CusId) + .FirstOrDefaultAsync(ct); + if (cusId == null) + return null; + + var belongs = await _dbContext.SubscriptionMembers.AnyAsync(member => + member.FKCustomerId == cusId.Value && + member.IsActive && + member.Subscription.IsActive && + member.Subscription.CustomerPlan.FKClientId == clientId, ct); + + return belongs ? cusId : null; + } + + public Task IsDeviceCommandableAsync( + Guid deviceId, + CancellationToken ct = default) + { + return _dbContext.AccessDevices.AnyAsync(device => + device.DeviceId == deviceId && + device.IsActive && + device.FKAgentId != null, ct); + } + + public async Task UpsertUnlinkedEnrollmentAsync(Guid deviceId, int deviceUserId, string? reportedName) + { + // Check already-tracked-but-unsaved entities first: a single attendance-ingest + // batch can carry several events for the same not-yet-linked device user (e.g. + // multiple punches since the last sync), and SaveChanges only runs once after the + // whole batch. A DB-only lookup wouldn't see an earlier Add() in this same batch + // and would insert a duplicate row per repeated punch. + var existing = _dbContext.UnlinkedDeviceEnrollments.Local + .FirstOrDefault(e => e.FKDeviceId == deviceId && e.DeviceUserId == deviceUserId && e.LinkedAt == null) + ?? await _dbContext.UnlinkedDeviceEnrollments + .FirstOrDefaultAsync(e => e.FKDeviceId == deviceId && e.DeviceUserId == deviceUserId && e.LinkedAt == null); + + if (existing != null) + { + if (!string.IsNullOrWhiteSpace(reportedName)) + existing.DeviceReportedName = reportedName; + return; + } + + await _dbContext.UnlinkedDeviceEnrollments.AddAsync(new UnlinkedDeviceEnrollment + { + Id = Guid.NewGuid(), + FKDeviceId = deviceId, + DeviceUserId = deviceUserId, + DeviceReportedName = reportedName, + DiscoveredAt = DateTime.UtcNow + }); + } + + public async Task> GetUnlinkedEnrollmentsByClientAsync(Guid clientId) + { + var enrollments = await _dbContext.UnlinkedDeviceEnrollments + .AsNoTracking() + .Include(e => e.AccessDevice) + .Where(e => e.LinkedAt == null && e.AccessDevice.FKClientId == clientId) + .OrderByDescending(e => e.DiscoveredAt) + .ToListAsync(); + + // Collapse any duplicate rows already sitting in the table (from before the + // upsert dedup fix above) so the same physical fingerprint doesn't show up + // multiple times in the link picker; keep the most recently discovered one. + return enrollments + .GroupBy(e => ( + e.FKDeviceId, + EmployeeNo: string.IsNullOrWhiteSpace(e.DeviceEmployeeNo) + ? e.DeviceUserId.ToString() + : e.DeviceEmployeeNo)) + .Select(g => g.First()) + .ToList(); + } + + public async Task GetUnlinkedEnrollmentByIdAsync(Guid id) + { + return await _dbContext.UnlinkedDeviceEnrollments + .Include(e => e.AccessDevice) + .FirstOrDefaultAsync(e => e.Id == id); + } + + public async Task CustomerAccessIdExistsAsync(int id) + { + return await _dbContext.CustomerAccess.AnyAsync(c => c.Id == id); + } + } +} diff --git a/Repos/ActivityLogRepository.cs b/Repos/ActivityLogRepository.cs new file mode 100644 index 0000000..bcc996e --- /dev/null +++ b/Repos/ActivityLogRepository.cs @@ -0,0 +1,88 @@ +using Fitness_core_BE.Models; +using Fitness_core_BE.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class ActivityLogRepository : IActivityLogRepository + { + private readonly CentralDbContext _dbContext; + + public ActivityLogRepository(CentralDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task AddActivityLogAsync(ActivityLog log, CancellationToken ct = default) + { + try + { + _dbContext.ActivityLogs.Add(log); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to add activity log: {ex.Message}"); + } + } + + public async Task> GetActivityLogsAsync( + string searchTerm = null, + DateTime? startDate = null, + DateTime? endDate = null, + CancellationToken ct = default) + { + try + { + var query = _dbContext.ActivityLogs + .AsNoTracking() + .Include(a => a.User) + .AsQueryable(); + + // Apply search filter + if (!string.IsNullOrEmpty(searchTerm)) + { + query = query.Where(a => + a.Description.Contains(searchTerm) || + a.UserName.Contains(searchTerm)); + } + + // Apply date range filters + if (startDate.HasValue) + { + query = query.Where(a => a.Timestamp >= startDate.Value); + } + + if (endDate.HasValue) + { + query = query.Where(a => a.Timestamp <= endDate.Value); + } + + return await query + .OrderByDescending(a => a.Timestamp) + .ToListAsync(ct); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to fetch activity logs: {ex.Message}"); + return new List(); + } + } + + public async Task GetUserNameAsync(Guid userId, CancellationToken ct = default) + { + try + { + var user = await _dbContext.Users + .AsNoTracking() + .FirstOrDefaultAsync(u => u.UserId == userId, ct); + + return user?.UserName ?? "Unknown User"; + } + catch (Exception ex) + { + Console.WriteLine($"Failed to fetch username: {ex.Message}"); + return "Unknown User"; + } + } + } +} diff --git a/Repos/AdminClientDirectoryRepository.cs b/Repos/AdminClientDirectoryRepository.cs new file mode 100644 index 0000000..5dd26a0 --- /dev/null +++ b/Repos/AdminClientDirectoryRepository.cs @@ -0,0 +1,52 @@ +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class AdminClientDirectoryRepository : IAdminClientDirectoryRepository + { + private readonly CentralDbContext _context; + + public AdminClientDirectoryRepository(CentralDbContext context) + { + _context = context; + } + + public async Task<(List Items, int Total)> ListAsync(string? search, int page, int pageSize, CancellationToken ct = default) + { + var query = _context.Client + .AsNoTracking() + .Include(c => c.ClientBusinesses) + .AsQueryable(); + + if (!string.IsNullOrWhiteSpace(search)) + query = query.Where(c => c.ClientName != null && c.ClientName.Contains(search)); + + var total = await query.CountAsync(ct); + + var items = await query + .OrderBy(c => c.ClientName) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(ct); + + return (items, total); + } + + public async Task GetByIdAsync(Guid clientId, CancellationToken ct = default) + { + return await _context.Client + .AsNoTracking() + .Include(c => c.ClientBusinesses) + .FirstOrDefaultAsync(c => c.ClientId == clientId, ct); + } + + public async Task ClientExistsAsync(Guid clientId, CancellationToken ct = default) + { + return await _context.Client + .AsNoTracking() + .AnyAsync(c => c.ClientId == clientId, ct); + } + } +} diff --git a/Repos/AdminSuggestionRepository.cs b/Repos/AdminSuggestionRepository.cs new file mode 100644 index 0000000..9310917 --- /dev/null +++ b/Repos/AdminSuggestionRepository.cs @@ -0,0 +1,21 @@ +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class AdminSuggestionRepository : IAdminSuggestionRepository + { + private readonly CentralDbContext _context; + public AdminSuggestionRepository(CentralDbContext context) => _context = context; + public async Task<(List Workouts, List Meals)> GetPublishedAsync(CancellationToken ct = default) => + (await _context.WorkoutPlans.AsNoTracking().Include(x => x.WorkoutCategory).Include(x => x.WorkoutItems).Where(x => x.FKClientId == null && x.IsTemplate && x.IsActive).OrderByDescending(x => x.CreateDate).ToListAsync(ct), + await _context.MealPlans.AsNoTracking().Include(x => x.MealCategory).Include(x => x.MealItems).Where(x => x.FKClientId == null && x.IsTemplate && x.IsActive).OrderByDescending(x => x.CreateDate).ToListAsync(ct)); + public Task FindWorkoutCategoryAsync(string name, CancellationToken ct = default) => _context.WorkoutCategories.FirstOrDefaultAsync(x => x.WorkoutCategoryName == name, ct); + public Task FindMealCategoryAsync(string name, CancellationToken ct = default) => _context.MealCategories.FirstOrDefaultAsync(x => x.Name == name, ct); + public Task AddWorkoutCategoryAsync(WorkoutCategory category, CancellationToken ct = default) => _context.WorkoutCategories.AddAsync(category, ct).AsTask(); + public Task AddMealCategoryAsync(MealCategory category, CancellationToken ct = default) => _context.MealCategories.AddAsync(category, ct).AsTask(); + public Task AddWorkoutAsync(WorkoutPlan plan, CancellationToken ct = default) => _context.WorkoutPlans.AddAsync(plan, ct).AsTask(); + public Task AddMealAsync(MealPlan plan, CancellationToken ct = default) => _context.MealPlans.AddAsync(plan, ct).AsTask(); + } +} diff --git a/Repos/AgentRepo.cs b/Repos/AgentRepo.cs new file mode 100644 index 0000000..12c0c36 --- /dev/null +++ b/Repos/AgentRepo.cs @@ -0,0 +1,54 @@ +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class AgentRepo : IAgentRepo + { + // Central: an agent authenticates via X-Api-Key alone, before any tenant is known. + // See CentralDbContext.Agents for the full rationale. + private readonly CentralDbContext _dbContext; + + public AgentRepo(CentralDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task AddAsync(Agent agent) + { + await _dbContext.Agents.AddAsync(agent); + return agent; + } + + public async Task GetByApiKeyHashAsync(string apiKeyHash) + { + return await _dbContext.Agents + .FirstOrDefaultAsync(a => a.ApiKeyHash == apiKeyHash && a.IsActive == true); + } + + public async Task GetByIdAsync(Guid agentId) + { + return await _dbContext.Agents + .FirstOrDefaultAsync(a => a.AgentId == agentId); + } + + public async Task> GetByClientIdAsync(Guid clientId) + { + return await _dbContext.Agents + .AsNoTracking() + .Where(a => a.FKClientId == clientId) + .OrderByDescending(a => a.CreatedAt) + .ToListAsync(); + } + + public async Task UpdateLastSeenAsync(Guid agentId, DateTime seenAt) + { + var agent = await _dbContext.Agents.FirstOrDefaultAsync(a => a.AgentId == agentId); + if (agent == null) + return; + + agent.LastSeenAt = seenAt; + } + } +} diff --git a/Repos/AttendanceRepository.cs b/Repos/AttendanceRepository.cs new file mode 100644 index 0000000..40f0055 --- /dev/null +++ b/Repos/AttendanceRepository.cs @@ -0,0 +1,191 @@ +using Fitness_core_BE.DTOs.Attendance; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class AttendanceRepository : IAttendanceRepository + { + private readonly TenantDbContext _dbContext; + + public AttendanceRepository(TenantDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task GetLatestAttendanceAsync(Guid clientId) + { + return await _dbContext.Attendances + .AsNoTracking() + .Where(a => a.FKClientId == clientId) + .OrderByDescending(a => a.EventTime) + .ThenByDescending(a => a.AttendanceId) + .FirstOrDefaultAsync(); + } + + public async Task> GetAttendanceKeysAsync(Guid clientId, DateTime fromTime) + { + var keys = await _dbContext.Attendances + .AsNoTracking() + .Where(a => a.FKClientId == clientId && a.EventTime >= fromTime) + .Select(a => new + { + a.FKDeviceId, + a.FKUserId, + a.FKCustomerId, + a.DeviceUserId, + a.DeviceEmployeeNo, + a.EventTime, + a.EventType + }) + .ToListAsync(); + + var set = new HashSet(); + foreach (var k in keys) + { + var userId = k.FKUserId ?? k.FKCustomerId ?? Guid.Empty; + var employeeNo = string.IsNullOrWhiteSpace(k.DeviceEmployeeNo) + ? k.DeviceUserId.ToString() + : k.DeviceEmployeeNo; + set.Add(BuildKey( + k.FKDeviceId ?? Guid.Empty, + userId, + employeeNo, + k.EventTime, + k.EventType)); + } + + return set; + } + + public async Task> GetSourceEventKeysAsync( + IReadOnlyCollection candidateKeys, + CancellationToken ct = default) + { + if (candidateKeys.Count == 0) + return new HashSet(); + + var keys = await _dbContext.Attendances + .AsNoTracking() + .Where(attendance => + attendance.SourceEventKey != null && + candidateKeys.Contains(attendance.SourceEventKey)) + .Select(attendance => attendance.SourceEventKey!) + .ToListAsync(ct); + + return keys.ToHashSet(); + } + + public async Task AddAttendancesAsync(IEnumerable attendances) + { + await _dbContext.Attendances.AddRangeAsync(attendances); + } + + public async Task> GetLatestPerPersonAsync(Guid clientId) + { + return await LatestPerPersonQuery(clientId) + .AsNoTracking() + .Include(a => a.TenantCustomerLink) + .Include(a => a.AccessProfile) + .OrderByDescending(a => a.EventTime) + .ThenByDescending(a => a.CreatedAt) + .ToListAsync(); + } + + public async Task> GetAttendanceCountsAsync(Guid clientId, DateTime fromDate, DateTime toDate) + { + var records = await _dbContext.Attendances + .AsNoTracking() + .Include(a => a.TenantCustomerLink) + .Where(a => a.FKClientId == clientId && a.FKCustomerId != null + && a.EventTime >= fromDate && a.EventTime <= toDate) + .ToListAsync(); + + return records + .GroupBy(a => a.FKCustomerId) + .Select(g => new AttendanceCountDTO + { + CustomerId = g.Key!.Value, + CustomerName = g.First().TenantCustomerLink?.DisplayName ?? "Unknown", + AttendanceCount = g.Count() + }) + .ToList(); + } + + public async Task> GetTodayAttendanceAsync(Guid clientId) + { + var todayStart = DateTime.UtcNow.Date; + var todayEnd = todayStart.AddDays(1); + + var records = await LatestPerPersonQuery(clientId, todayStart, todayEnd) + .AsNoTracking() + .Include(a => a.TenantCustomerLink) + .Include(a => a.AccessProfile) + .OrderByDescending(a => a.EventTime) + .ThenByDescending(a => a.CreatedAt) + .ToListAsync(); + + return records.Select(a => new TodayAttendanceDTO + { + CustomerName = a.TenantCustomerLink?.DisplayName + ?? a.AccessProfile?.DisplayName + ?? "Unknown", + EventTime = a.EventTime, + EventType = a.EventType + }).ToList(); + } + + private IQueryable LatestPerPersonQuery( + Guid clientId, + DateTime? from = null, + DateTime? to = null) + { + var scoped = _dbContext.Attendances + .Where(a => a.FKClientId == clientId); + + if (from.HasValue) + scoped = scoped.Where(a => a.EventTime >= from.Value); + if (to.HasValue) + scoped = scoped.Where(a => a.EventTime < to.Value); + + // Raw device events remain available for reports and counts. This + // read query exposes only the newest state for each linked person. + // User/customer matching also bridges legacy rows created before + // AccessProfileId was populated. + return scoped.Where(record => + !scoped.Any(candidate => + ( + (record.FKUserId.HasValue && + candidate.FKUserId == record.FKUserId) || + (record.FKCustomerId.HasValue && + candidate.FKCustomerId == record.FKCustomerId) || + (record.FKAccessProfileId.HasValue && + candidate.FKAccessProfileId == record.FKAccessProfileId) || + (!record.FKUserId.HasValue && + !record.FKCustomerId.HasValue && + !record.FKAccessProfileId.HasValue && + !candidate.FKUserId.HasValue && + !candidate.FKCustomerId.HasValue && + !candidate.FKAccessProfileId.HasValue && + candidate.FKDeviceId == record.FKDeviceId && + candidate.DeviceEmployeeNo == record.DeviceEmployeeNo) + ) && + ( + candidate.EventTime > record.EventTime || + (candidate.EventTime == record.EventTime && + candidate.CreatedAt > record.CreatedAt) + ))); + } + + private static string BuildKey( + Guid deviceId, + Guid userId, + string deviceEmployeeNo, + DateTime eventTime, + string eventType) + { + return $"{deviceId}|{userId}|{deviceEmployeeNo}|{eventTime:O}|{eventType}"; + } + } +} diff --git a/Repos/BookingRepository.cs b/Repos/BookingRepository.cs new file mode 100644 index 0000000..94b3fc5 --- /dev/null +++ b/Repos/BookingRepository.cs @@ -0,0 +1,54 @@ +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class BookingRepository : IBookingRepository + { + private readonly TenantDbContext _context; + + public BookingRepository(TenantDbContext context) + { + _context = context; + } + + public async Task CreateBooking(Booking booking) + { + booking.BookingId = Guid.NewGuid(); + booking.CreatedAt = DateTime.UtcNow; + + _context.Bookings.Add(booking); + return booking; + } + + public Task GetBookingById(Guid bookingId) + => _context.Bookings + .Include(b => b.FacilitySession).ThenInclude(s => s.Facility) + .FirstOrDefaultAsync(b => b.BookingId == bookingId); + + public Task GetActiveBookingByOtp(string otpCode) + => _context.Bookings + .Include(b => b.FacilitySession).ThenInclude(s => s.Facility) + .FirstOrDefaultAsync(b => b.OtpCode == otpCode && b.Status == "Confirmed"); + + public Task> GetBookingsForSession(Guid facilitySessionId) + => _context.Bookings + .Where(b => b.FKFacilitySessionId == facilitySessionId) + .OrderBy(b => b.BookedAt) + .ToListAsync(); + + public Task> GetBookingsForCustomer(Guid customerId) + => _context.Bookings + .Include(b => b.FacilitySession).ThenInclude(s => s.Facility) + .Where(b => b.FKCustomerId == customerId) + .OrderByDescending(b => b.BookedAt) + .ToListAsync(); + + public Task CustomerHasActiveBookingForSession(Guid facilitySessionId, Guid customerId) + => _context.Bookings.AnyAsync(b => + b.FKFacilitySessionId == facilitySessionId && + b.FKCustomerId == customerId && + b.Status != "Cancelled"); + } +} diff --git a/Repos/ChatRepository.cs b/Repos/ChatRepository.cs new file mode 100644 index 0000000..c32bedd --- /dev/null +++ b/Repos/ChatRepository.cs @@ -0,0 +1,69 @@ +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class ChatRepository : IChatRepo + { + private readonly CentralDbContext _context; + + public ChatRepository(CentralDbContext context) + { + _context = context; + } + + public async Task GetChatByIdAsync(Guid chatId) + { + return await _context.Chat + .AsNoTracking() + .FirstOrDefaultAsync(c => c.Id == chatId && c.IsAcitve); + } + + public async Task GetChatBetweenUsersAsync(Guid userAId, Guid userBId) + { + return await _context.Chat + .AsNoTracking() + .FirstOrDefaultAsync(c => + c.IsAcitve && + ( + (c.FKSenderId == userAId && c.FKReceiverId == userBId) || + (c.FKSenderId == userBId && c.FKReceiverId == userAId) + )); + } + + public async Task CreateChatAsync(Chat chat) + { + await _context.Chat.AddAsync(chat); + } + + public async Task AddMessageAsync(ChatMessages message) + { + await _context.ChatMessages.AddAsync(message); + } + + public async Task> GetMessagesAsync( + Guid chatId, + int skip, + int take) + { + return await _context.ChatMessages + .AsNoTracking() + .Where(m => m.FKChatId == chatId && m.IsAcitve) + .OrderBy(m => m.CreatedAt) + .Skip(skip) + .Take(take) + .ToListAsync(); + } + + public async Task IsUserInChatAsync(Guid chatId, Guid userId) + { + return await _context.Chat + .AsNoTracking() + .AnyAsync(c => + c.Id == chatId && + c.IsAcitve && + (c.FKSenderId == userId || c.FKReceiverId == userId)); + } + } +} diff --git a/Repos/ClientInvoiceRepository.cs b/Repos/ClientInvoiceRepository.cs new file mode 100644 index 0000000..435f81d --- /dev/null +++ b/Repos/ClientInvoiceRepository.cs @@ -0,0 +1,143 @@ +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Fitness_core_BE; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class ClientInvoiceRepository : IClientInvoiceRepository + { + private readonly CentralDbContext _context; + + public ClientInvoiceRepository(CentralDbContext context) + { + _context = context; + } + + public async Task CreateClientInvoiceFromSubscription(Guid subscriptionId) + { + var subscription = await _context.ClientSubscriptions + .Include(cs => cs.ClientPackage) + .Include(cs => cs.Client) + .ThenInclude(c => c.User) + .FirstOrDefaultAsync(cs => cs.SubscriptionId == subscriptionId); + + if (subscription == null) + { + throw new Exception("Subscription not found"); + } + + var subItems = await _context.ClientSubItems + .Where(si => si.FKClientSubscriptionId == subscriptionId && si.IsActive) + .ToListAsync(); + + decimal totalAmount = subscription.ClientPackage.PackagePrice; + totalAmount += subItems.Sum(si => si.SubItemPrice); + + var clientCard = await _context.ClientCard + .FirstOrDefaultAsync(cc => cc.FK_ClientId == subscription.Client.ClientId); + + if (clientCard == null) + { + throw new Exception("Client card not found"); + } + + var clientInvoice = new ClientInvoice + { + ClientInvoiceId = Guid.NewGuid(), + FKClientSubscriptionId = subscriptionId, + FKUserId = subscription.Client.FKUserId, + FKClientCardId = clientCard.CardId, + Amount = totalAmount, + Status = "Pending", + ClientInvoiceDate = DateTime.UtcNow + }; + + _context.ClientInvoices.Add(clientInvoice); + + var packageItem = new ClientInvoiceItem + { + ClientInvoiceItemId = Guid.NewGuid(), + FKClientInvoiceId = clientInvoice.ClientInvoiceId, + ItemName = subscription.ClientPackage.PackageName, + ItemDescription = subscription.ClientPackage.PackageDescription, + ItemPrice = subscription.ClientPackage.PackagePrice, + CreatedAt = DateTime.UtcNow + }; + + _context.ClientInvoiceItems.Add(packageItem); + + foreach (var subItem in subItems) + { + var clientInvoiceItem = new ClientInvoiceItem + { + ClientInvoiceItemId = Guid.NewGuid(), + FKClientInvoiceId = clientInvoice.ClientInvoiceId, + ItemName = subItem.SubItemName, + ItemDescription = subItem.SubItemDescription, + ItemPrice = subItem.SubItemPrice, + CreatedAt = DateTime.UtcNow + }; + + _context.ClientInvoiceItems.Add(clientInvoiceItem); + } + + return clientInvoice; + } + + public async Task GetClientInvoiceById(Guid clientInvoiceId) + { + return await _context.ClientInvoices + .Include(i => i.ClientSubscription) + .ThenInclude(cs => cs.ClientPackage) + .Include(i => i.ClientSubscription) + .ThenInclude(cs => cs.Client) + .Include(i => i.User) + .Include(i => i.ClientCard) + .Include(i => i.Items) + .FirstOrDefaultAsync(i => i.ClientInvoiceId == clientInvoiceId); + } + + public async Task> GetClientInvoicesByClientId(Guid clientId) + { + return await _context.ClientInvoices + .Include(i => i.ClientSubscription) + .ThenInclude(cs => cs.ClientPackage) + .Include(i => i.User) + .Include(i => i.ClientCard) + .Include(i => i.Items) + .Where(i => i.ClientSubscription.FKClientId == clientId) + .OrderByDescending(i => i.ClientInvoiceDate) + .ToListAsync(); + } + + public async Task> GetClientInvoicesByUserId(Guid userId) + { + return await _context.ClientInvoices + .Include(i => i.ClientSubscription) + .ThenInclude(cs => cs.ClientPackage) + .Include(i => i.ClientSubscription) + .ThenInclude(cs => cs.Client) + .Include(i => i.ClientCard) + .Include(i => i.Items) + .Where(i => i.FKUserId == userId) + .OrderByDescending(i => i.ClientInvoiceDate) + .ToListAsync(); + } + + public async Task UpdateClientInvoiceStatus(Guid clientInvoiceId, string status) + { + var clientInvoice = await _context.ClientInvoices.FindAsync(clientInvoiceId); + + if (clientInvoice == null) + { + throw new Exception("ClientInvoice not found"); + } + + clientInvoice.Status = status; + + return clientInvoice; + } + + } +} diff --git a/Repos/ClientRepository.cs b/Repos/ClientRepository.cs new file mode 100644 index 0000000..ddf5179 --- /dev/null +++ b/Repos/ClientRepository.cs @@ -0,0 +1,826 @@ +using Fitness_core_BE.Domain; +using Fitness_core_BE.DTOs.Customer; +using Fitness_core_BE.DTOs.CustomerPlan; +using Fitness_core_BE.DTOs.Meal; +using Fitness_core_BE.DTOs.Workout; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + + public class ClientRepository : IClientRepository + { + // Tenant-scoped operational data (staff, memberships, invoices, access control, ...). + private readonly TenantDbContext _dbContext; + // Central identity/registry data (Customer, Client, ClientBusiness, admin templates, + // platform billing). + private readonly CentralDbContext _central; + + public ClientRepository(TenantDbContext dbContext, CentralDbContext central) + { + _dbContext = dbContext; + _central = central; + } + + + public async Task> GetAllCustomers() + { + return await _central.Customer + .Where(c => c.IsActive == true) + .ToListAsync(); + } + + public async Task GetCustomerById(Guid customerId) + { + var customer = await _central.Customer + .AsNoTracking() + .Include(c => c.User) + .Include(c => c.CustomerTargets.Where(ct => ct.IsActive)) + .ThenInclude(ct => ct.WeeklyProgresses.Where(wp => wp.IsActive)) + .FirstOrDefaultAsync(c => c.CusId == customerId && c.IsActive); + if (customer == null) + return null; + + // CustomerSubscriptions/CustomerWorkouts/CustomerMeals are tenant-only -- load + // this tenant's slice separately and attach in memory (mirrors what a single + // cross-database Include could no longer do). + customer.CustomerSubscriptions = await _dbContext.CustomerSubscriptions + .AsNoTracking() + .Include(cs => cs.CustomerPlan) + .Where(cs => cs.FKCustomerId == customerId) + .ToListAsync(); + customer.CustomerWorkouts = await _dbContext.CustomerWorkouts + .AsNoTracking() + .Where(cw => cw.FKCustomerId == customerId && cw.IsActive) + .ToListAsync(); + customer.CustomerMeals = await _dbContext.CustomerMeals + .AsNoTracking() + .Where(cm => cm.FKCustomerId == customerId && cm.IsActive) + .ToListAsync(); + + return customer; + } + + + + public async Task> GetAllClientsAsync(CancellationToken ct = default) + { + return await _central.Client + .AsNoTracking() + .Include(c => c.User) + .ToListAsync(ct); + } + + public async Task GetClientByIdAsync(Guid clientId, CancellationToken ct = default) + { + return await _central.Client + .AsNoTracking() + // Keep the profile read aligned with the same primary active + // business row that UpdateGymProfile mutates. + .Include(c => c.ClientBusinesses + .Where(b => b.IsActive) + .OrderBy(b => b.createdAt)) + .FirstOrDefaultAsync(c => c.ClientId == clientId, ct); + } + + public async Task> GetClientListAsync(Guid clientId, CancellationToken ct = default) + { + var customers = await _central.Customer + + .Where(c => c.FKUserId == clientId) + .ToListAsync(ct); + + return customers; + } + + public async Task RegisterClientAsync(Client client, CancellationToken ct = default) + { + await _central.Client.AddAsync(client, ct); + return new { client.ClientId }; + } + + + public async Task> GetCustomersByClientIdAsync(Guid clientId, CancellationToken ct = default) + { + // One row per member via SubscriptionMembers so group secondaries appear + // alongside primaries, each pointing at the shared subscription. + var memberships = await _dbContext.SubscriptionMembers + .AsNoTracking() + .Where(m => m.IsActive + && m.TenantCustomerLink.IsActiveAtThisClient + && m.Subscription.IsActive + && m.Subscription.CustomerPlan.FKClientId == clientId) + .Include(m => m.TenantCustomerLink) + .Include(m => m.Subscription) + .ThenInclude(s => s.CustomerPlan) + .OrderByDescending(m => m.Subscription.OnboardDate) + .ToListAsync(ct); + + // Latest subscription per member (mirrors the previous one-row-per-member semantic). + var latestPerMember = memberships + .GroupBy(m => m.FKCustomerId) + .Select(g => g.First()) + .ToList(); + + var subscriptionIds = latestPerMember.Select(m => m.FKSubscriptionId).Distinct().ToList(); + + // Batch: latest current invoice per subscription. IsActive excludes an unapproved + // renewal invoice awaiting staff review; RolledOver excludes one already superseded + // by a newer invoice that carried its balance forward -- without both, this list + // could show either a not-yet-billed amount or a double-counted balance. + var invoices = await _dbContext.CustomerInvoices + .AsNoTracking() + .Where(i => subscriptionIds.Contains(i.FKCustomerSubscriptionId) + && i.IsActive && i.Status != "Cancelled" && i.Status != "RolledOver") + .GroupBy(i => i.FKCustomerSubscriptionId) + .Select(g => g.OrderByDescending(i => i.CustomerInvoiceDate).First()) + .ToListAsync(ct); + var invoiceBySub = invoices.ToDictionary(i => i.FKCustomerSubscriptionId); + + // Batch: approved paid sums per invoice. + var invoiceIds = invoices.Select(i => i.CustomerInvoiceId).ToList(); + var paidSums = await _dbContext.PaymentTransactions + .AsNoTracking() + .Where(p => p.FK_CustomerInvoiceId != null + && invoiceIds.Contains(p.FK_CustomerInvoiceId.Value) + && p.IsApproved == true + && p.IsActive == true) + .GroupBy(p => p.FK_CustomerInvoiceId!.Value) + .Select(g => new { InvoiceId = g.Key, Paid = g.Sum(p => p.PayAmount) }) + .ToDictionaryAsync(x => x.InvoiceId, x => x.Paid, ct); + + // Batch: active member counts per subscription. + var memberCounts = await _dbContext.SubscriptionMembers + .AsNoTracking() + .Where(m => subscriptionIds.Contains(m.FKSubscriptionId) && m.IsActive && m.IsPlanAssigned) + .GroupBy(m => m.FKSubscriptionId) + .Select(g => new { SubscriptionId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.SubscriptionId, x => x.Count, ct); + + // Batch: tenant-scoped access profiles and their terminal bindings. + var customerUserIds = latestPerMember + .Where(m => m.TenantCustomerLink != null) + .Select(m => m.TenantCustomerLink.CentralUserId) + .Distinct() + .ToList(); + var accessByUser = await _dbContext.AccessProfiles + .AsNoTracking() + .Include(p => p.DeviceBindings) + .ThenInclude(b => b.AccessDevice) + .Where(p => p.FKClientId == clientId && customerUserIds.Contains(p.FKUserId)) + .ToDictionaryAsync(p => p.FKUserId, p => p, ct); + + var rows = latestPerMember.Select(m => + { + var subscription = m.Subscription; + var plan = subscription.CustomerPlan; + invoiceBySub.TryGetValue(subscription.SubscriptionId, out var invoice); + + // Legacy subscriptions without an invoice still show the plan price as owed. + var invoiceAmount = invoice?.Amount ?? plan?.PlanPrice ?? 0m; + var paid = invoice != null && paidSums.TryGetValue(invoice.CustomerInvoiceId, out var sum) ? sum : 0m; + var balance = invoiceAmount - paid; + accessByUser.TryGetValue(m.TenantCustomerLink.CentralUserId, out var access); + var bindings = access?.DeviceBindings + .Where(b => b.AccessDevice.IsActive && b.AccessDevice.FKAgentId != null) + .ToList() ?? new List(); + var provision = access == null + ? "NotConfigured" + : bindings.Count == 0 ? "NoDevice" + : bindings.Any(b => b.ProvisionStatus == "Failed") ? "Failed" + : bindings.All(b => b.ProvisionStatus == "Synced") ? "Synced" : "Pending"; + var actual = bindings.Count == 0 ? "Unknown" + : bindings.All(b => b.ActualAccessState == access!.DesiredAccessState) + ? access.DesiredAccessState + : bindings.All(b => b.ActualAccessState == "Unknown") + ? "Unknown" + : "Mixed"; + + return new MemberListRowDto + { + FingerprintId = access != null && access.AccessProfileId <= int.MaxValue + ? (int)access.AccessProfileId : null, + FingerprintName = access?.DisplayName, + AccessProfileId = access?.AccessProfileId, + AccessNumber = bindings.FirstOrDefault()?.DeviceEmployeeNo + ?? (access == null ? null : $"HF{access.AccessProfileId}"), + AccessProvisionStatus = provision, + ActualAccessState = actual, + DesiredAccessState = access?.DesiredAccessState ?? "Enabled", + FingerprintCount = bindings.Count == 0 ? 0 : bindings.Max(b => b.FingerprintCount), + AccessLastError = bindings.FirstOrDefault(b => !string.IsNullOrWhiteSpace(b.LastError))?.LastError, + IsLocked = access?.DesiredAccessState == "Disabled", + LockReason = access?.LockReason, + HasFacePhoto = access?.HasFacePhoto ?? false, + FacePhotoStatus = access?.FacePhotoStatus ?? "None", + FacePhotoError = access?.FacePhotoError, + SubscriptionId = subscription.SubscriptionId, + FKCustomerPlanId = subscription.FKCustomerPlanId, + FKCustomerId = m.FKCustomerId, + OnboardDate = subscription.OnboardDate, + ExpiryDate = subscription.ExpiryDate, + RenewalDate = subscription.RenewalDate, + IsActive = subscription.IsActive, + CreatedAt = subscription.CreatedAt, + Customer = BuildDisplayCustomer(m.TenantCustomerLink), + IsPrimary = m.IsPrimary, + IsPlanAssigned = m.IsPlanAssigned, + PlanName = plan?.PlanName, + PlanScope = plan?.PlanScope ?? "Individual", + MemberCount = memberCounts.TryGetValue(subscription.SubscriptionId, out var count) ? count : 1, + MemberLimit = plan?.MemberLimit ?? 1, + InvoiceAmount = invoiceAmount, + PaidAmount = paid, + Balance = balance, + PaymentStatus = paid >= invoiceAmount && invoiceAmount > 0 + ? "Paid" + : paid > 0 ? "Partial" : "Pending", + AllowSplitPayments = plan?.AllowSplitPayments ?? false + }; + }).ToList(); + + // Customers created without a plan yet have no SubscriptionMember row, so they'd + // otherwise be invisible here — they're still this gym's members (linked via the + // AccessProfile created at creation time, same as everyone else) and need to show + // up so staff can find them and assign a plan (as primary or as a group member). + var linkedCustomerIds = rows.Select(r => r.FKCustomerId).ToHashSet(); + var planlessLinkIds = await _dbContext.AccessProfiles + .AsNoTracking() + .Where(p => p.FKClientId == clientId && p.PersonType == "Customer") + .Join(_dbContext.TenantCustomerLink.Where(l => l.IsActiveAtThisClient), + p => p.FKUserId, l => l.CentralUserId, + (p, l) => l.TenantCustomerLinkId) + .Where(cusId => !linkedCustomerIds.Contains(cusId)) + .Distinct() + .ToListAsync(ct); + + if (planlessLinkIds.Count > 0) + { + // Same tenant-only cache as the main list above -- no central round-trip. + var planlessLinks = await _dbContext.TenantCustomerLink + .AsNoTracking() + .Where(l => planlessLinkIds.Contains(l.TenantCustomerLinkId) && l.IsActiveAtThisClient) + .ToListAsync(ct); + + rows.AddRange(planlessLinks.Select(l => new MemberListRowDto + { + SubscriptionId = Guid.Empty, + FKCustomerPlanId = Guid.Empty, + FKCustomerId = l.TenantCustomerLinkId, + OnboardDate = l.CreatedAt, + ExpiryDate = null, + RenewalDate = null, + IsActive = l.IsActiveAtThisClient, + CreatedAt = l.CreatedAt, + Customer = BuildDisplayCustomer(l), + IsPrimary = true, + PlanName = null, + PlanScope = "None", + MemberCount = 0, + MemberLimit = 0, + InvoiceAmount = 0, + PaidAmount = 0, + Balance = 0, + PaymentStatus = "NoPlan", + AllowSplitPayments = false + })); + } + + return rows.OrderByDescending(r => r.CreatedAt).ToList(); + } + + // Builds a display-only Customer for the member list from this gym's own cached copy + // (TenantCustomerLink) instead of a central-DB round-trip. Only name/phone/email/active + // are cached here -- gender/DOB/NIC/login-username are central-only and are fetched by + // GetMemberById (client/customers/{id}) when a single member's full profile is needed + // (e.g. opening the edit form), not for every row in the list. + private static Models.Customer BuildDisplayCustomer(TenantCustomerLink link) => new() + { + CusId = link.TenantCustomerLinkId, + CusName = link.DisplayName, + PrimaryPH = link.PrimaryPhoneCached, + Email = link.EmailCached, + FKUserId = link.CentralUserId, + IsActive = link.IsActiveAtThisClient, + CreatedAt = link.CreatedAt, + }; + + public async Task CustomerBelongsToClientAsync(Guid customerId, Guid clientId, CancellationToken ct = default) + { + var hasSubscription = await _dbContext.SubscriptionMembers + .AsNoTracking() + .AnyAsync(member => member.FKCustomerId == customerId + && member.IsActive + && member.Subscription.IsActive + && member.Subscription.CustomerPlan.FKClientId == clientId, ct); + if (hasSubscription) + return true; + + // Plan-less customers have no SubscriptionMember row yet — fall back to the + // AccessProfile link created at member-creation time (see GetCustomersByClientIdAsync). + // Central lookup for the customer's shared login UserId, then a tenant-scoped check. + var userId = await _central.Customer + .AsNoTracking() + .Where(c => c.CusId == customerId) + .Select(c => (Guid?)c.FKUserId) + .FirstOrDefaultAsync(ct); + if (userId == null) + return false; + + return await _dbContext.AccessProfiles + .AsNoTracking() + .AnyAsync(p => p.FKClientId == clientId && p.PersonType == "Customer" && p.FKUserId == userId.Value, ct); + } + + public async Task> GetCustomerPlansByClientIdAsync(Guid clientId, CancellationToken ct = default) + { + return await _dbContext.CustomerPlans + .Where(p => p.FKClientId == clientId && p.IsActive) + .AsNoTracking() + .OrderByDescending(p => p.CreatedAt) + .ToListAsync(ct); + } + + public async Task GetCustomerPlanEntityForClientAsync(Guid customerPlanId, Guid clientId, CancellationToken ct = default) + { + return await _dbContext.CustomerPlans + .AsNoTracking() + .Where(p => p.CustomerPlanId == customerPlanId && p.FKClientId == clientId && p.IsActive) + .FirstOrDefaultAsync(ct); + } + + // Unlike CustomerSubscription.FKCustomerId (which only ever points at the primary + // member), this resolves the caller's own SubscriptionMember row whether they're + // the primary or a secondary member of a group subscription. + public async Task GetActiveMembershipForCustomerAsync(Guid customerId, CancellationToken ct = default) + { + return await _dbContext.SubscriptionMembers + .Include(m => m.Subscription) + .ThenInclude(s => s.CustomerPlan) + .Where(m => m.FKCustomerId == customerId && m.IsActive && m.Subscription.IsActive) + .OrderByDescending(m => m.Subscription.CreatedAt) + .FirstOrDefaultAsync(ct); + } + + public async Task DeactivateSubscriptionAsync(Guid subscriptionId, CancellationToken ct = default) + { + var subscription = await _dbContext.CustomerSubscriptions + .FirstOrDefaultAsync(cs => cs.SubscriptionId == subscriptionId, ct); + if (subscription == null) + return; + + subscription.IsActive = false; + + var members = await _dbContext.SubscriptionMembers + .Where(m => m.FKSubscriptionId == subscriptionId && m.IsActive) + .ToListAsync(ct); + foreach (var member in members) + member.IsActive = false; + + var subItems = await _dbContext.CustomerSubItems + .Where(si => si.FKCustomerSubscriptionId == subscriptionId && si.IsActive) + .ToListAsync(ct); + foreach (var item in subItems) + item.IsActive = false; + + var installments = await _dbContext.SubscriptionInstallments + .Where(i => i.FKSubscriptionId == subscriptionId && i.IsActive) + .ToListAsync(ct); + foreach (var installment in installments) + installment.IsActive = false; + } + + public async Task DeactivateSubscriptionMemberAsync(Guid subscriptionMemberId, CancellationToken ct = default) + { + var member = await _dbContext.SubscriptionMembers + .FirstOrDefaultAsync(m => m.SubscriptionMemberId == subscriptionMemberId && m.IsActive, ct); + if (member != null) + member.IsActive = false; + } + + // Unassign/re-assign only moves IsPlanAssigned. IsActive is left alone so the member + // stays in the gym's member list (and keeps their device enrollment) while sitting + // without a plan, and so the same button can put them straight back on it. + public async Task SetMembershipPlanAssignedAsync( + Guid subscriptionId, bool assigned, bool wholeSubscription, Guid subscriptionMemberId, + CancellationToken ct = default) + { + var members = wholeSubscription + ? await _dbContext.SubscriptionMembers + .Where(m => m.FKSubscriptionId == subscriptionId && m.IsActive) + .ToListAsync(ct) + : await _dbContext.SubscriptionMembers + .Where(m => m.SubscriptionMemberId == subscriptionMemberId && m.IsActive) + .ToListAsync(ct); + + foreach (var member in members) + member.IsPlanAssigned = assigned; + } + + // The membership to act on for assign/unassign, ignoring IsPlanAssigned so an + // already-unassigned member can be found again and put back on their plan. + public async Task GetMembershipForCustomerAsync( + Guid customerId, CancellationToken ct = default) + { + return await _dbContext.SubscriptionMembers + .Include(m => m.Subscription) + .ThenInclude(s => s.CustomerPlan) + .Where(m => m.FKCustomerId == customerId && m.IsActive && m.Subscription.IsActive) + .OrderByDescending(m => m.Subscription.CreatedAt) + .FirstOrDefaultAsync(ct); + } + + public async Task ReplaceCustomerSubscriptionAsync(SubscriptionMember? oldMembership, CustomerSubscriptionAggregate newAggregate, CancellationToken ct = default) + { + if (oldMembership != null) + { + if (oldMembership.IsPrimary) + await DeactivateSubscriptionAsync(oldMembership.FKSubscriptionId, ct); + else + await DeactivateSubscriptionMemberAsync(oldMembership.SubscriptionMemberId, ct); + } + + await _dbContext.CustomerSubscriptions.AddAsync(newAggregate.Subscription, ct); + await _dbContext.CustomerSubItems.AddAsync(newAggregate.InitialSubItem, ct); + await _dbContext.SubscriptionMembers.AddAsync(newAggregate.PrimaryMember, ct); + await _dbContext.SubscriptionInstallments.AddRangeAsync(newAggregate.Installments, ct); + } + + public async Task GetCustomerPlanByIdForClientAsync(Guid customerPlanId, Guid clientId, CancellationToken ct = default) + { + return await _dbContext.CustomerPlans + .AsNoTracking() + .Where(p => p.CustomerPlanId == customerPlanId && p.FKClientId == clientId && p.IsActive) + .Select(p => new CustomerPlanDto + { + CustomerPlanId = p.CustomerPlanId, + FKClientId = p.FKClientId, + ClientName = null, + PlanName = p.PlanName, + PlanDescription = p.PlanDescription, + PlanPrice = p.PlanPrice, + DurationInDays = p.DurationInDays, + IsActive = p.IsActive, + CreatedAt = p.CreatedAt + }) + .FirstOrDefaultAsync(ct); + } + + public async Task RegisterBussinessDetailAsync(ClientBusiness clientBusiness, CancellationToken ct = default) + { + await _central.ClientBusiness.AddAsync(clientBusiness, ct); + } + + public async Task GetPrimaryBusinessByClientIdAsync(Guid clientId, CancellationToken ct = default) + { + // Tracked query — the profile editor mutates this entity and persists via UoW. + return await _central.ClientBusiness + .Where(b => b.FKClientId == clientId && b.IsActive) + .OrderBy(b => b.createdAt) + .FirstOrDefaultAsync(ct); + } + + public async Task GetClientEntityByIdAsync(Guid clientId, CancellationToken ct = default) + { + // Tracked — used to mirror contact-detail changes back onto the Client record. + return await _central.Client.FirstOrDefaultAsync(c => c.ClientId == clientId, ct); + } + + public async Task> GetFollowedWorkoutPlansAsync(Guid customerId, CancellationToken ct = default) + { + var follows = await _dbContext.CustomerWorkouts + .AsNoTracking() + .Where(cw => cw.FKCustomerId == customerId && cw.IsActive) + .Select(cw => new { cw.FKWorkoutPlanId, cw.IsFollowing, cw.CompletedDate }) + .ToListAsync(ct); + if (follows.Count == 0) + return new List(); + + var planIds = follows.Select(f => f.FKWorkoutPlanId).Distinct().ToList(); + var names = await ResolveWorkoutPlanNamesAsync(planIds, ct); + + return follows + .Select(f => new FollowedPlanDto(f.FKWorkoutPlanId, names.GetValueOrDefault(f.FKWorkoutPlanId), f.IsFollowing, f.CompletedDate)) + .ToList(); + } + + public async Task> GetFollowedMealPlansAsync(Guid customerId, CancellationToken ct = default) + { + var follows = await _dbContext.CustomerMeals + .AsNoTracking() + .Where(cm => cm.FKCustomerId == customerId && cm.IsActive) + .Select(cm => new { cm.FKMealPlanId, cm.IsFollowing, cm.CompletedDate }) + .ToListAsync(ct); + if (follows.Count == 0) + return new List(); + + var planIds = follows.Select(f => f.FKMealPlanId).Distinct().ToList(); + var names = await ResolveMealPlanNamesAsync(planIds, ct); + + return follows + .Select(f => new FollowedPlanDto(f.FKMealPlanId, names.GetValueOrDefault(f.FKMealPlanId), f.IsFollowing, f.CompletedDate)) + .ToList(); + } + + // A followed WorkoutPlan/MealPlan id could be an admin template (central) or this + // gym's own custom plan (tenant) -- resolve names from both buckets and merge. + private async Task> ResolveWorkoutPlanNamesAsync(List planIds, CancellationToken ct) + { + var central = await _central.WorkoutPlans + .AsNoTracking() + .Where(wp => planIds.Contains(wp.WorkoutPlanId)) + .ToDictionaryAsync(wp => wp.WorkoutPlanId, wp => (string?)wp.WorkoutPlanName, ct); + var missing = planIds.Except(central.Keys).ToList(); + if (missing.Count > 0) + { + var tenant = await _dbContext.WorkoutPlans + .AsNoTracking() + .Where(wp => missing.Contains(wp.WorkoutPlanId)) + .ToDictionaryAsync(wp => wp.WorkoutPlanId, wp => (string?)wp.WorkoutPlanName, ct); + foreach (var kv in tenant) + central[kv.Key] = kv.Value; + } + return central; + } + + private async Task> ResolveMealPlanNamesAsync(List planIds, CancellationToken ct) + { + var central = await _central.MealPlans + .AsNoTracking() + .Where(mp => planIds.Contains(mp.MealPlanId)) + .ToDictionaryAsync(mp => mp.MealPlanId, mp => (string?)mp.Name, ct); + var missing = planIds.Except(central.Keys).ToList(); + if (missing.Count > 0) + { + var tenant = await _dbContext.MealPlans + .AsNoTracking() + .Where(mp => missing.Contains(mp.MealPlanId)) + .ToDictionaryAsync(mp => mp.MealPlanId, mp => (string?)mp.Name, ct); + foreach (var kv in tenant) + central[kv.Key] = kv.Value; + } + return central; + } + + public async Task GetCustomerHealthAsync(Guid customerId, CancellationToken ct = default) + { + return await _central.CustomerHealth + .AsNoTracking() + .Where(h => h.FKCusId == customerId && h.IsActive) + .OrderByDescending(h => h.CreatedAt) + .FirstOrDefaultAsync(ct); + } + + // Client Package operations + public async Task> GetPackageListAsync(CancellationToken ct = default) + { + return await _central.ClientPackages + .AsNoTracking() + .Where(cp => cp.IsActive) + .OrderBy(cp => cp.PackageName) + .ToListAsync(ct); + } + + public async Task GetPackageByIdAsync(Guid id, CancellationToken ct = default) + { + return await _central.ClientPackages + .AsNoTracking() + .FirstOrDefaultAsync(cp => cp.ClientPackageId == id && cp.IsActive, ct); + } + + public async Task> GetActiveGymsAsync(CancellationToken ct = default) + { + return await _central.ClientBusiness + .Where(x => x.IsActive == true + && x.Latitude != null + && x.Longitude != null) + .ToListAsync(ct); + } + + public async Task> GetWorkoutPlansByClientIdAsync(Guid clientId, CancellationToken ct = default) + { + return await _dbContext.WorkoutPlans + .AsNoTracking() + .Include(wp => wp.WorkoutCategory) + .Include(wp => wp.CustomerWorkouts.Where(cw => cw.IsActive)) + .ThenInclude(cw => cw.TenantCustomerLink) + .Where(wp => wp.IsActive && wp.FKClientId == clientId) + .OrderByDescending(wp => wp.CreateDate) + .Select(wp => new WorkoutPlanDto + { + WorkoutPlanId = wp.WorkoutPlanId, + Name = wp.WorkoutPlanName, + Description = wp.Description, + IsTemplate = wp.IsTemplate, + WorkoutCategoryId = wp.FKWorkoutCategoryId, + WorkoutCategoryName = wp.WorkoutCategory != null ? wp.WorkoutCategory.WorkoutCategoryName : "", + ClientId = wp.FKClientId, + ClientName = null, + IsActive = wp.IsActive, + CreateDate = wp.CreateDate, + AssignedCustomers = wp.CustomerWorkouts + .Where(cw => cw.IsActive) + .Select(cw => new CustomerWorkoutDto + { + CustomerWorkoutId = cw.CustomerWorkoutId, + CustomerId = cw.FKCustomerId, + CustomerName = cw.TenantCustomerLink != null ? cw.TenantCustomerLink.DisplayName : null, + WorkoutPlanId = cw.FKWorkoutPlanId, + IsActive = cw.IsActive, + IsFollowing = cw.IsFollowing, + AssignedDate = cw.AssignedDate, + CompletedDate = cw.CompletedDate, + Notes = cw.Notes + }).ToList() + }) + .ToListAsync(ct); + } + + public async Task GetWorkoutPlanByIdForClientAsync(Guid workoutPlanId, Guid clientId, CancellationToken ct = default) + { + var workoutPlan = await _dbContext.WorkoutPlans + .AsNoTracking() + .Include(wp => wp.WorkoutCategory) + .Include(wp => wp.CustomerWorkouts.Where(cw => cw.IsActive)) + .ThenInclude(cw => cw.TenantCustomerLink) + .Include(wp => wp.WorkoutItems) + .FirstOrDefaultAsync(wp => wp.WorkoutPlanId == workoutPlanId && wp.FKClientId == clientId && wp.IsActive, ct); + + if (workoutPlan == null) + return null; + + return new WorkoutPlanDetailDto + { + WorkoutPlanId = workoutPlan.WorkoutPlanId, + Name = workoutPlan.WorkoutPlanName, + Description = workoutPlan.Description, + IsTemplate = workoutPlan.IsTemplate, + WorkoutCategoryId = workoutPlan.FKWorkoutCategoryId, + WorkoutCategoryName = workoutPlan.WorkoutCategory != null ? workoutPlan.WorkoutCategory.WorkoutCategoryName : "", + ClientId = workoutPlan.FKClientId, + ClientName = null, + IsActive = workoutPlan.IsActive, + CreateDate = workoutPlan.CreateDate, + WorkoutItems = workoutPlan.WorkoutItems + .Where(wi => wi.IsActive) + .Select(wi => new WorkoutItemDto + { + WorkoutItemId = wi.WorkoutItemId, + ExerciseDay = wi.ExerciseDay, + Description = wi.Description, + Exercises = wi.Exercises, + HowToDo = wi.HowToDo, + DoURL = wi.DoURL, + Duration = wi.Duration, + WormUp = wi.WormUp, + Cooldown = wi.Cooldown, + WorkoutPlanId = wi.FKWorkoutPlanId, + IsActive = wi.IsActive, + CreateDate = wi.CreateDate, + UpdatedAt = wi.UpdatedAt + }) + .ToList(), + AssignedCustomers = workoutPlan.CustomerWorkouts + .Where(cw => cw.IsActive) + .Select(cw => new CustomerWorkoutDto + { + CustomerWorkoutId = cw.CustomerWorkoutId, + CustomerId = cw.FKCustomerId, + CustomerName = cw.TenantCustomerLink != null ? cw.TenantCustomerLink.DisplayName : null, + WorkoutPlanId = cw.FKWorkoutPlanId, + IsActive = cw.IsActive, + IsFollowing = cw.IsFollowing, + AssignedDate = cw.AssignedDate, + CompletedDate = cw.CompletedDate, + Notes = cw.Notes + }).ToList() + }; + } + + public async Task> GetMealPlansByClientIdAsync(Guid clientId, CancellationToken ct = default) + { + return await _dbContext.MealPlans + .AsNoTracking() + .Include(mp => mp.MealCategory) + .Include(mp => mp.CustomerMeals.Where(cm => cm.IsActive)) + .ThenInclude(cm => cm.TenantCustomerLink) + .Where(mp => mp.IsActive && mp.FKClientId == clientId) + .OrderByDescending(mp => mp.CreateDate) + .Select(mp => new MealPlanDto + { + MealPlanId = mp.MealPlanId, + Name = mp.Name, + Description = mp.Description, + IsTemplate = mp.IsTemplate, + MealCategoryId = mp.FKMealCategoryId, + MealCategoryName = mp.MealCategory != null ? mp.MealCategory.Name : "", + ClientId = mp.FKClientId, + ClientName = null, + IsActive = mp.IsActive, + CreateDate = mp.CreateDate, + AssignedCustomers = mp.CustomerMeals + .Where(cm => cm.IsActive) + .Select(cm => new CustomerMealDto + { + CustomerMealId = cm.CustomerMealId, + CustomerId = cm.FKCustomerId, + CustomerName = cm.TenantCustomerLink != null ? cm.TenantCustomerLink.DisplayName : null, + MealPlanId = cm.FKMealPlanId, + IsActive = cm.IsActive, + IsFollowing = cm.IsFollowing, + AssignedDate = cm.AssignedDate, + CompletedDate = cm.CompletedDate, + Notes = cm.Notes + }).ToList() + }) + .ToListAsync(ct); + } + + public async Task GetMealPlanByIdForClientAsync(Guid mealPlanId, Guid clientId, CancellationToken ct = default) + { + var mealPlan = await _dbContext.MealPlans + .AsNoTracking() + .Include(mp => mp.MealCategory) + .Include(mp => mp.CustomerMeals.Where(cm => cm.IsActive)) + .ThenInclude(cm => cm.TenantCustomerLink) + .Include(mp => mp.MealItems) + .FirstOrDefaultAsync(mp => mp.MealPlanId == mealPlanId && mp.FKClientId == clientId && mp.IsActive, ct); + + if (mealPlan == null) + return null; + + var mealItems = mealPlan.MealItems + .Where(mi => mi.IsActive) + .Select(mi => new MealDto + { + MealId = mi.MealId, + Name = mi.Name, + Description = mi.Description, + Calories = mi.Calories, + Protein = mi.Protein, + Carbohydrates = mi.Carbohydrates, + Fats = mi.Fats, + ServingSize = mi.ServingSize, + MealItems = mi.MealItems, + PreparationNotes = mi.PreparationNotes, + MealPlanId = mi.FKMealPlanId, + IsActive = mi.IsActive, + CreateDate = mi.CreateDate + }) + .ToList(); + + return new MealPlanDetailDto + { + MealPlanId = mealPlan.MealPlanId, + Name = mealPlan.Name, + Description = mealPlan.Description, + IsTemplate = mealPlan.IsTemplate, + MealCategoryId = mealPlan.FKMealCategoryId, + MealCategoryName = mealPlan.MealCategory != null ? mealPlan.MealCategory.Name : "", + ClientId = mealPlan.FKClientId, + ClientName = null, + IsActive = mealPlan.IsActive, + CreateDate = mealPlan.CreateDate, + MealItems = mealItems, + AssignedCustomers = mealPlan.CustomerMeals + .Where(cm => cm.IsActive) + .Select(cm => new CustomerMealDto + { + CustomerMealId = cm.CustomerMealId, + CustomerId = cm.FKCustomerId, + CustomerName = cm.TenantCustomerLink != null ? cm.TenantCustomerLink.DisplayName : null, + MealPlanId = cm.FKMealPlanId, + IsActive = cm.IsActive, + IsFollowing = cm.IsFollowing, + AssignedDate = cm.AssignedDate, + CompletedDate = cm.CompletedDate, + Notes = cm.Notes + }).ToList(), + TotalCalories = mealItems.Sum(mi => mi.Calories ?? 0), + TotalProtein = mealItems.Sum(mi => mi.Protein ?? 0), + TotalCarbohydrates = mealItems.Sum(mi => mi.Carbohydrates ?? 0), + TotalFats = mealItems.Sum(mi => mi.Fats ?? 0) + }; + } + + public async Task> GetCardsByClientIdAsync(Guid clientId, CancellationToken ct = default) + { + return await _dbContext.CustomerCards + .AsNoTracking() + .Where(c => c.FK_ClientId == clientId && c.IsActive) + .ToListAsync(ct); + } + + // Scoped to a single member, and to the caller's own gym — used by the dashboard to show + // whether a member has a consented card before offering the auto-charge toggle. + public async Task> GetCardsByCustomerForClientAsync(Guid customerId, Guid clientId, CancellationToken ct = default) + { + return await _dbContext.CustomerCards + .AsNoTracking() + .Where(c => c.FK_CustomerId == customerId && c.FK_ClientId == clientId && c.IsActive) + .ToListAsync(ct); + } + } +} diff --git a/Repos/ClientStatRepository.cs b/Repos/ClientStatRepository.cs new file mode 100644 index 0000000..86514e1 --- /dev/null +++ b/Repos/ClientStatRepository.cs @@ -0,0 +1,330 @@ +using Fitness_core_BE.DTOs.Stats; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class ClientStatRepository : IClientStatRepository + { + private readonly TenantDbContext _dbContext; + + public ClientStatRepository(TenantDbContext dbContext) + { + _dbContext = dbContext; + } + + // Member counts are driven by SubscriptionMembers so group secondaries + // (couple/family) are counted, not just subscription primaries. + private IQueryable ActiveMembersForClient(Guid clientId) + { + return _dbContext.SubscriptionMembers + .Where(m => m.Subscription.CustomerPlan.FKClientId == clientId + && m.Subscription.IsActive + && m.IsActive + && m.TenantCustomerLink.IsActiveAtThisClient); + } + + public async Task GetTotalCustomerCountAsync(Guid clientId, CancellationToken ct = default) + { + return await ActiveMembersForClient(clientId) + .Select(m => m.FKCustomerId) + .Distinct() + .CountAsync(ct); + } + + public async Task GetMonthlyCustomerCountAsync(Guid clientId, CancellationToken ct = default) + { + var startOfMonth = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc); + + return await ActiveMembersForClient(clientId) + .Where(m => m.JoinedAt >= startOfMonth) + .Select(m => m.FKCustomerId) + .Distinct() + .CountAsync(ct); + } + + public async Task GetTotalWorkoutPlanCountAsync(Guid clientId, CancellationToken ct = default) + { + return await _dbContext.WorkoutPlans + .Where(w => w.FKClientId == clientId && w.IsActive) + .CountAsync(ct); + } + + public async Task GetTotalMealPlanCountAsync(Guid clientId, CancellationToken ct = default) + { + return await _dbContext.MealPlans + .Where(m => m.FKClientId == clientId && m.IsActive) + .CountAsync(ct); + } + + public async Task GetLastMonthCustomerCountAsync(Guid clientId, CancellationToken ct = default) + { + var now = DateTime.UtcNow; + var startOfLastMonth = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(-1); + var startOfThisMonth = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc); + + return await ActiveMembersForClient(clientId) + .Where(m => m.JoinedAt >= startOfLastMonth && m.JoinedAt < startOfThisMonth) + .Select(m => m.FKCustomerId) + .Distinct() + .CountAsync(ct); + } + + public async Task> GetLastFiveAttendancesAsync(Guid clientId, CancellationToken ct = default) + { + return await _dbContext.Attendances + .Where(a => a.FKClientId == clientId) + .Include(a => a.TenantCustomerLink) + .OrderByDescending(a => a.EventTime) + .Take(5) + .AsNoTracking() + .ToListAsync(ct); + } + + public async Task> GetLastFivePaymentsAsync(Guid clientId, CancellationToken ct = default) + { + var payments = await _dbContext.PaymentTransactions + .Where(p => p.FK_ClientId == clientId && p.IsActive == true) + .Include(p => p.CustomerInvoice) + .ThenInclude(ci => ci.CustomerSubscription) + .ThenInclude(cs => cs.TenantCustomerLink) + .OrderByDescending(p => p.PayDate) + .Take(5) + .AsNoTracking() + .ToListAsync(ct); + + // PaymentTransaction.Customer isn't a real relationship post-split (Customer + // identity lives in the central DB, this table is tenant-scoped) -- backfill a + // display-only Customer shape from the cached TenantCustomerLink so this response's + // existing customer.cusName shape keeps working for API consumers. + foreach (var payment in payments) + { + var link = payment.CustomerInvoice?.CustomerSubscription?.TenantCustomerLink; + if (link != null) + { + payment.Customer = new Customer + { + CusId = link.TenantCustomerLinkId, + CusName = link.DisplayName, + PrimaryPH = link.PrimaryPhoneCached, + Email = link.EmailCached + }; + } + } + + return payments; + } + + // Revenue counts only approved payments (owner-recorded payments are approved + // on creation; pending online attempts are excluded). + public async Task GetLastMonthRevenueAsync(Guid clientId, CancellationToken ct = default) + { + var now = DateTime.UtcNow; + var startOfLastMonth = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(-1); + var startOfThisMonth = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc); + + return await _dbContext.PaymentTransactions + .Where(p => p.FK_ClientId == clientId + && p.IsActive == true + && p.IsApproved == true + && p.PayDate >= startOfLastMonth + && p.PayDate < startOfThisMonth) + .SumAsync(p => p.PayAmount, ct); + } + + public async Task> GetMonthlyRevenueVariationAsync( + Guid clientId, DateTime? from = null, DateTime? to = null, CancellationToken ct = default) + { + DateTime rangeStart; + DateTime rangeEndExclusive; + bool byHour; + bool byDay; + + if (from.HasValue && to.HasValue) + { + rangeStart = DateTime.SpecifyKind(from.Value.Date, DateTimeKind.Utc); + rangeEndExclusive = DateTime.SpecifyKind(to.Value.Date.AddDays(1), DateTimeKind.Utc); + var totalDays = (rangeEndExclusive - rangeStart).TotalDays; + // A single-day range (Today/Yesterday) would otherwise collapse to one bucket, + // so break it down by hour instead so the chart still shows a trend. + byHour = totalDays <= 1; + byDay = !byHour && totalDays <= 31; + } + else + { + // Default (no explicit range): trailing 10 calendar months, unchanged from before. + var now = DateTime.UtcNow; + rangeStart = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(-9); + rangeEndExclusive = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(1); + byHour = false; + byDay = false; + } + + DateTime BucketKey(DateTime d) => + byHour ? new DateTime(d.Year, d.Month, d.Day, d.Hour, 0, 0, DateTimeKind.Utc) + : byDay ? d.Date + : new DateTime(d.Year, d.Month, 1, 0, 0, 0, DateTimeKind.Utc); + + var payments = await _dbContext.PaymentTransactions + .Where(p => p.FK_ClientId == clientId + && p.IsActive == true + && p.IsApproved == true + && p.PayDate >= rangeStart + && p.PayDate < rangeEndExclusive) + .Select(p => new { p.PayDate, p.PayAmount }) + .AsNoTracking() + .ToListAsync(ct); + + var newMembers = await ActiveMembersForClient(clientId) + .Where(m => m.JoinedAt >= rangeStart && m.JoinedAt < rangeEndExclusive) + .Select(m => new { m.JoinedAt, m.FKCustomerId }) + .AsNoTracking() + .ToListAsync(ct); + + var revenueByBucket = payments + .GroupBy(p => BucketKey(p.PayDate!.Value)) + .ToDictionary(g => g.Key, g => g.Sum(p => p.PayAmount)); + + var newMembersByBucket = newMembers + .GroupBy(m => BucketKey(m.JoinedAt)) + .ToDictionary(g => g.Key, g => g.Select(m => m.FKCustomerId).Distinct().Count()); + + var buckets = new List(); + for (var d = rangeStart; d < rangeEndExclusive; d = byHour ? d.AddHours(1) : byDay ? d.AddDays(1) : d.AddMonths(1)) + buckets.Add(d); + + return buckets + .Select(bucket => new MonthlyRevenueStat + { + Year = bucket.Year, + Month = bucket.Month, + MonthLabel = byHour ? bucket.ToString("h tt") : byDay ? bucket.ToString("MMM d") : bucket.ToString("MMM yyyy"), + Revenue = revenueByBucket.TryGetValue(bucket, out var rev) ? rev : 0m, + NewMemberCount = newMembersByBucket.TryGetValue(bucket, out var cnt) ? cnt : 0 + }) + .ToList(); + } + + public async Task GetExpiringSoonCountAsync(Guid clientId, DateTime from, DateTime to, CancellationToken ct = default) + { + return await ActiveMembersForClient(clientId) + .Where(m => m.Subscription.RenewalDate != null + && m.Subscription.RenewalDate >= from + && m.Subscription.RenewalDate <= to) + .Select(m => m.FKCustomerId) + .Distinct() + .CountAsync(ct); + } + public async Task GetTotalRevenueAsync(Guid clientId, CancellationToken ct = default) + { + return await _dbContext.PaymentTransactions + .Where(p => p.FK_ClientId == clientId && p.IsActive == true && p.IsApproved == true) + .SumAsync(p => p.PayAmount, ct); + } + + public async Task GetClientStatSummaryAsync( + Guid clientId, DateTime? from = null, DateTime? to = null, CancellationToken ct = default) + { + DateTime periodStart; + DateTime periodEndExclusive; + DateTime comparisonStart; + + if (from.HasValue && to.HasValue) + { + periodStart = DateTime.SpecifyKind(from.Value.Date, DateTimeKind.Utc); + periodEndExclusive = DateTime.SpecifyKind(to.Value.Date.AddDays(1), DateTimeKind.Utc); + // Equal-length window immediately preceding the selected period, for growth %. + comparisonStart = periodStart - (periodEndExclusive - periodStart); + } + else + { + var now = DateTime.UtcNow; + periodStart = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc); + periodEndExclusive = periodStart.AddMonths(1); + comparisonStart = periodStart.AddMonths(-1); + } + + int totalCustomers = await ActiveMembersForClient(clientId) + .Select(m => m.FKCustomerId) + .Distinct() + .CountAsync(ct); + + int thisPeriodCustomers = await ActiveMembersForClient(clientId) + .Where(m => m.JoinedAt >= periodStart && m.JoinedAt < periodEndExclusive) + .Select(m => m.FKCustomerId) + .Distinct() + .CountAsync(ct); + + int lastPeriodCustomers = await ActiveMembersForClient(clientId) + .Where(m => m.JoinedAt >= comparisonStart && m.JoinedAt < periodStart) + .Select(m => m.FKCustomerId) + .Distinct() + .CountAsync(ct); + + decimal thisPeriodRevenue = await _dbContext.PaymentTransactions + .Where(p => p.FK_ClientId == clientId && + p.IsActive == true && + p.IsApproved == true && + p.PayDate >= periodStart && + p.PayDate < periodEndExclusive) + .SumAsync(p => p.PayAmount, ct); + + decimal lastPeriodRevenue = await _dbContext.PaymentTransactions + .Where(p => p.FK_ClientId == clientId && + p.IsActive == true && + p.IsApproved == true && + p.PayDate >= comparisonStart && + p.PayDate < periodStart) + .SumAsync(p => p.PayAmount, ct); + + decimal totalRevenue = await _dbContext.PaymentTransactions + .Where(p => p.FK_ClientId == clientId && + p.IsActive == true && + p.IsApproved == true) + .SumAsync(p => p.PayAmount, ct); + + decimal customerGrowthPct = lastPeriodCustomers == 0 + ? (thisPeriodCustomers > 0 ? 100m : 0m) + : Math.Round((decimal)(thisPeriodCustomers - lastPeriodCustomers) + / lastPeriodCustomers * 100, 2); + + decimal revenueGrowthPct = lastPeriodRevenue == 0 + ? (thisPeriodRevenue > 0 ? 100m : 0m) + : Math.Round((thisPeriodRevenue - lastPeriodRevenue) + / lastPeriodRevenue * 100, 2); + + // OverdueCount is filled in by ClientStatService (needs IOverdueLockRepository). + var expiryWindowStart = DateTime.UtcNow; + int expiringSoon = await GetExpiringSoonCountAsync(clientId, expiryWindowStart, expiryWindowStart.AddDays(7), ct); + + // "Today" always means today regardless of the from/to period filter above -- + // matches the AttendanceRepository convention (DateTime.UtcNow.Date), not the + // selected reporting window. + var todayStart = DateTime.UtcNow.Date; + var todayEndExclusive = todayStart.AddDays(1); + var todayPayments = await _dbContext.PaymentTransactions + .Where(p => p.FK_ClientId == clientId && + p.IsActive == true && + p.IsApproved == true && + p.PayDate >= todayStart && + p.PayDate < todayEndExclusive) + .ToListAsync(ct); + + return new ClientStatSummaryDto + { + TotalCustomerCount = totalCustomers, + ThisMonthCustomerCount = thisPeriodCustomers, + LastMonthCustomerCount = lastPeriodCustomers, + CustomerGrowthPercentage = customerGrowthPct, + ThisMonthRevenue = thisPeriodRevenue, + LastMonthRevenue = lastPeriodRevenue, + RevenueGrowthPercentage = revenueGrowthPct, + TotalRevenue = totalRevenue, + ExpiringWithin7DaysCount = expiringSoon, + TodayPaymentsCount = todayPayments.Count, + TodayPaymentsTotal = todayPayments.Sum(p => p.PayAmount) + }; + } + } +} diff --git a/Repos/CustomerInvoiceRepository.cs b/Repos/CustomerInvoiceRepository.cs new file mode 100644 index 0000000..9ef17c6 --- /dev/null +++ b/Repos/CustomerInvoiceRepository.cs @@ -0,0 +1,569 @@ +using Fitness_core_BE.Domain; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Fitness_core_BE; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class CustomerInvoiceRepository(TenantDbContext context) : ICustomerInvoiceRepository + { + // Shared with BuildAndAddInvoice (writes it), RepointAndRecalculateInvoiceAsync (preserves + // it by name) and GetCarriedForwardAmountsAsync (reads it) -- one constant so the three + // sites can't drift apart. + private const string PreviousBalanceItemName = "Previous Balance"; + + private readonly TenantDbContext _context = context; + + public async Task CreateCustomerInvoiceFromSubscription( + Guid subscriptionId, bool chargePayment = true, decimal carriedOverBalance = 0m, bool includeOnboardingFee = true) + { + // Idempotency: invoices are now created automatically at subscription time, + // so a second call (e.g. via the legacy function-handler route) must not duplicate. + // Not used by the daily renewal sweep (CreateRenewalInvoiceAsync) -- under the + // renewal-approval model the same subscription legitimately gets a new invoice every + // cycle, which this guard would otherwise block. Credit/Applied don't count as "the" + // invoice either -- a Credit-only subscription (e.g. right after a downgrade) still + // needs a real invoice built the next time one is due, which is what actually consumes + // that open credit via ConsumeOpenCreditAsync inside BuildAndAddInvoice. + var existingInvoice = await _context.CustomerInvoices + .Where(i => i.FKCustomerSubscriptionId == subscriptionId + && i.Status != "Cancelled" && i.Status != "RolledOver" + && i.Status != "Credit" && i.Status != "Applied") + .OrderByDescending(i => i.CustomerInvoiceDate) + .FirstOrDefaultAsync(); + if (existingInvoice != null) + { + return existingInvoice; + } + + var subscription = await LoadSubscriptionForInvoiceAsync(subscriptionId); + var subItems = await LoadActiveSubItemsAsync(subscriptionId); + // Only consume credit when there's actually something to apply it to -- a + // chargePayment=false invoice is already Rs. 0, so burning the customer's open credit + // on it would just destroy that value instead of applying it to anything. + var openCredit = chargePayment + ? await ConsumeOpenCreditAsync(subscription.TenantCustomerLink.CentralUserId, subscription.CustomerPlan.FKClientId) + : 0m; + + return BuildAndAddInvoice( + subscription, subItems, chargePayment, carriedOverBalance, openCredit, includeOnboardingFee, isActive: true); + } + + // Guard-free sibling of CreateCustomerInvoiceFromSubscription for the daily renewal + // sweep: unlike a normal subscription (one invoice, created once), the same + // CustomerSubscription now persists across many renewal cycles and legitimately gets a + // new invoice each time, so the "an invoice already exists" idempotency guard above does + // not apply here. Duplicate-renewal protection instead lives in the renewal candidate + // query itself (SubscriptionRenewalRunner excludes subscriptions with an + // already-pending, unapproved invoice). Renewals never re-bill the one-time onboarding + // fee, and the new invoice starts IsActive=false pending staff approval. + public async Task CreateRenewalInvoiceAsync( + Guid subscriptionId, decimal carriedOverBalance, CancellationToken ct = default) + { + var subscription = await LoadSubscriptionForInvoiceAsync(subscriptionId, ct); + var subItems = await LoadActiveSubItemsAsync(subscriptionId, ct); + var openCredit = await ConsumeOpenCreditAsync( + subscription.TenantCustomerLink.CentralUserId, subscription.CustomerPlan.FKClientId, ct); + + return BuildAndAddInvoice( + subscription, subItems, chargePayment: true, carriedOverBalance, openCredit, + includeOnboardingFee: false, isActive: false); + } + + private async Task LoadSubscriptionForInvoiceAsync( + Guid subscriptionId, CancellationToken ct = default) + { + var subscription = await _context.CustomerSubscriptions + .Include(cs => cs.CustomerPlan) + .Include(cs => cs.TenantCustomerLink) + .FirstOrDefaultAsync(cs => cs.SubscriptionId == subscriptionId, ct); + + if (subscription == null) + { + throw new Exception("Subscription not found"); + } + + return subscription; + } + + private Task> LoadActiveSubItemsAsync(Guid subscriptionId, CancellationToken ct = default) + { + return _context.CustomerSubItems + .Where(si => si.FKCustomerSubscriptionId == subscriptionId && si.IsActive) + .ToListAsync(ct); + } + + // Shared itemization/discount/carry-forward logic behind both invoice-creation entry + // points above. Adds the invoice and its line items to the context but does not save -- + // callers control the transaction boundary. + private CustomerInvoice BuildAndAddInvoice( + Fitness_core_BE.Models.CustomerSubscription subscription, + List subItems, + bool chargePayment, + decimal carriedOverBalance, + decimal openCredit, + bool includeOnboardingFee, + bool isActive) + { + decimal onboardingFee = includeOnboardingFee && subscription.CustomerPlan.HasOnboardingFee + ? subscription.CustomerPlan.OnboardingFeeAmount + : 0m; + decimal subtotal = subscription.CustomerPlan.PlanPrice + onboardingFee; + subtotal += subItems.Sum(si => si.SubItemPrice); + + decimal discountAmount = DiscountCalculator.ComputeDiscountAmount( + subtotal, subscription.DiscountType, subscription.DiscountValue); + decimal totalAmount = subtotal - discountAmount; + + // Already-earned debt from a prior period isn't discountable goods -- add it after + // the discount, not into the subtotal it's computed from. + if (carriedOverBalance > 0) + totalAmount += carriedOverBalance; + + // Owner chose not to charge (e.g. comped/waived membership) — keep the itemized + // breakdown for record-keeping but the invoice owes nothing. + if (!chargePayment) + totalAmount = 0m; + + // Any open credit (e.g. from a prior mid-cycle downgrade) applies automatically to + // the next invoice, whatever kind it is -- floored at 0 rather than going negative; + // this is a regular invoice, not a credit note, so unused credit beyond this bill + // simply stays open for the one after it (ConsumeOpenCreditAsync already marked the + // whole thing "Applied" up front, so a same-transaction leftover is not re-split here). + decimal creditApplied = Math.Min(openCredit, Math.Max(0m, totalAmount)); + if (creditApplied > 0) + totalAmount -= creditApplied; + + var customerInvoice = new CustomerInvoice + { + CustomerInvoiceId = Guid.NewGuid(), + FKCustomerSubscriptionId = subscription.SubscriptionId, + FKClientId = subscription.CustomerPlan.FKClientId, + FKUserId = subscription.TenantCustomerLink.CentralUserId, + Amount = totalAmount, + // A Rs. 0 invoice (chargePayment=false, a 100% discount, or credit covering the + // whole bill) has nothing left to collect -- leaving it "Pending" would show up + // as outstanding debt forever with no way to mark it paid. + Status = totalAmount == 0m ? "Paid" : "Pending", + IsActive = isActive, + CustomerInvoiceDate = DateTime.UtcNow + }; + + _context.CustomerInvoices.Add(customerInvoice); + + AddInvoiceLineItems(customerInvoice.CustomerInvoiceId, subscription, subItems, onboardingFee, discountAmount); + + if (carriedOverBalance > 0) + { + _context.CustomerInvoiceItems.Add(new CustomerInvoiceItem + { + CustomerInvoiceItemId = Guid.NewGuid(), + FKCustomerInvoiceId = customerInvoice.CustomerInvoiceId, + ItemName = PreviousBalanceItemName, + ItemDescription = "Outstanding balance carried over from the previous subscription period", + ItemPrice = carriedOverBalance, + CreatedAt = DateTime.UtcNow + }); + } + + if (creditApplied > 0) + { + _context.CustomerInvoiceItems.Add(new CustomerInvoiceItem + { + CustomerInvoiceItemId = Guid.NewGuid(), + FKCustomerInvoiceId = customerInvoice.CustomerInvoiceId, + ItemName = "Credit Applied", + ItemDescription = "Credit balance from a prior plan change, applied to this invoice", + ItemPrice = -creditApplied, + CreatedAt = DateTime.UtcNow + }); + } + + return customerInvoice; + } + + // Finds every still-open Credit invoice for this customer at this gym and consumes all + // of it in one shot (marks each "Applied"), returning the total as a positive amount for + // the caller to subtract from whatever invoice is being built right now. Credit invoices + // are created by CreatePlanChangeInvoiceAsync when a downgrade leaves the customer having + // overpaid -- this is the other half of that: applying it the next time they're billed. + private async Task ConsumeOpenCreditAsync(Guid userId, Guid clientId, CancellationToken ct = default) + { + var creditInvoices = await _context.CustomerInvoices + .Where(i => i.FKUserId == userId && i.FKClientId == clientId && i.Status == "Credit") + .ToListAsync(ct); + if (creditInvoices.Count == 0) + return 0m; + + decimal totalCredit = 0m; + foreach (var invoice in creditInvoices) + { + totalCredit += -invoice.Amount; // Amount is stored negative on a Credit invoice + invoice.Status = "Applied"; + } + + return totalCredit; + } + + // Plan/onboarding-fee/sub-item/discount line items -- shared by BuildAndAddInvoice and + // RepointAndRecalculateInvoiceAsync (the "Previous Balance" carry-forward item is handled + // separately by each caller, since RepointAndRecalculateInvoiceAsync must leave an + // existing one untouched rather than recreate it). + private void AddInvoiceLineItems( + Guid customerInvoiceId, + Fitness_core_BE.Models.CustomerSubscription subscription, + List subItems, + decimal onboardingFee, + decimal discountAmount) + { + _context.CustomerInvoiceItems.Add(new CustomerInvoiceItem + { + CustomerInvoiceItemId = Guid.NewGuid(), + FKCustomerInvoiceId = customerInvoiceId, + ItemName = subscription.CustomerPlan.PlanName, + ItemDescription = subscription.CustomerPlan.PlanDescription, + ItemPrice = subscription.CustomerPlan.PlanPrice, + CreatedAt = DateTime.UtcNow + }); + + if (onboardingFee > 0) + { + _context.CustomerInvoiceItems.Add(new CustomerInvoiceItem + { + CustomerInvoiceItemId = Guid.NewGuid(), + FKCustomerInvoiceId = customerInvoiceId, + ItemName = "Onboarding Fee", + ItemDescription = "One-time gym onboarding fee", + ItemPrice = onboardingFee, + CreatedAt = DateTime.UtcNow + }); + } + + foreach (var subItem in subItems) + { + _context.CustomerInvoiceItems.Add(new CustomerInvoiceItem + { + CustomerInvoiceItemId = Guid.NewGuid(), + FKCustomerInvoiceId = customerInvoiceId, + ItemName = subItem.SubItemName, + ItemDescription = subItem.SubItemDescription, + ItemPrice = subItem.SubItemPrice, + CreatedAt = DateTime.UtcNow + }); + } + + if (discountAmount > 0) + { + _context.CustomerInvoiceItems.Add(new CustomerInvoiceItem + { + CustomerInvoiceItemId = Guid.NewGuid(), + FKCustomerInvoiceId = customerInvoiceId, + ItemName = "Discount", + ItemDescription = subscription.DiscountType == "Percentage" + ? $"{subscription.DiscountValue}% discount" + : $"Flat discount of Rs. {subscription.DiscountValue}", + ItemPrice = -discountAmount, + CreatedAt = DateTime.UtcNow + }); + } + } + + public Task HasPendingApprovalInvoiceAsync(Guid subscriptionId) + { + return _context.CustomerInvoices.AnyAsync(i => + i.FKCustomerSubscriptionId == subscriptionId && i.IsActive == false && i.Status != "Cancelled"); + } + + // Renewal-approval workflow: the unapproved invoices awaiting staff review. + public async Task> GetUnapprovedInvoicesByClientIdAsync(Guid clientId) + { + return await _context.CustomerInvoices + .Include(i => i.CustomerSubscription) + .ThenInclude(cs => cs.CustomerPlan) + .Include(i => i.CustomerSubscription) + .ThenInclude(cs => cs.TenantCustomerLink) + .Where(i => i.FKClientId == clientId && i.IsActive == false && i.Status != "Cancelled") + .OrderByDescending(i => i.CustomerInvoiceDate) + .ToListAsync(); + } + + // Regular invoices tab: one row per customer (their latest approved, currently-relevant + // invoice) instead of every invoice they've ever had. "RolledOver" is excluded alongside + // "Cancelled": once an invoice's balance has been rolled into a newer one (see + // SubscriptionRenewalRunner), it's superseded -- surfacing it here as "the latest" would + // double-count that balance against the new invoice's own carried-forward line item. + public async Task> GetLatestApprovedInvoicePerCustomerAsync(Guid clientId) + { + // Cancelled is the only status excluded up front -- RolledOver and not-yet-approved + // (IsActive=false) invoices are still real candidates below when they're the most + // recent thing on the customer's active subscription (mid-renewal: the old cycle's + // invoice is RolledOver and the new one is awaiting approval, so *neither* would + // pass the old "IsActive && not RolledOver" filter, leaving nothing on that + // subscription to show and wrongly falling back to a long-superseded invoice). + var all = await _context.CustomerInvoices + .Include(i => i.CustomerSubscription) + .ThenInclude(cs => cs.CustomerPlan) + .Include(i => i.CustomerSubscription) + .ThenInclude(cs => cs.TenantCustomerLink) + .Where(i => i.FKClientId == clientId && i.Status != "Cancelled") + .ToListAsync(); + + return all + .GroupBy(i => i.CustomerSubscription.FKCustomerId) + .Select(g => + { + // A customer has at most one active subscription -- whatever their most + // recent invoice on it is (even RolledOver or still-unapproved) is more + // representative of where they actually stand than a fully-settled invoice + // from a subscription that's been inactive for months. + var onActiveSubscription = g + .Where(i => i.CustomerSubscription.IsActive) + .OrderByDescending(i => i.CustomerInvoiceDate) + .FirstOrDefault(); + if (onActiveSubscription != null) + return onActiveSubscription; + + // No active subscription (e.g. removed from the gym, or between plan + // changes) -- fall back to their last genuinely-collectible invoice. + return g.Where(i => i.IsActive && i.Status != "RolledOver") + .OrderByDescending(i => i.CustomerInvoiceDate) + .FirstOrDefault(); + }) + .Where(i => i != null) + .Select(i => i!) + .OrderByDescending(i => i.CustomerInvoiceDate) + .ToList(); + } + + // Change-Plan approval action: repoints a pending renewal invoice at the freshly-created + // replacement subscription and recalculates the "new round" portion from the new plan's + // price -- any carried-forward "Previous Balance" line item is left exactly as-is. + public async Task RepointAndRecalculateInvoiceAsync( + Guid customerInvoiceId, Guid newSubscriptionId, CancellationToken ct = default) + { + var invoice = await _context.CustomerInvoices + .FirstOrDefaultAsync(i => i.CustomerInvoiceId == customerInvoiceId, ct); + if (invoice == null) + { + throw new Exception("Invoice not found"); + } + + var previousBalanceItem = await _context.CustomerInvoiceItems + .FirstOrDefaultAsync(item => + item.FKCustomerInvoiceId == customerInvoiceId && item.ItemName == PreviousBalanceItemName, ct); + decimal carriedOverBalance = previousBalanceItem?.ItemPrice ?? 0m; + + var staleItems = await _context.CustomerInvoiceItems + .Where(item => item.FKCustomerInvoiceId == customerInvoiceId && item.ItemName != PreviousBalanceItemName) + .ToListAsync(ct); + _context.CustomerInvoiceItems.RemoveRange(staleItems); + + var newSubscription = await LoadSubscriptionForInvoiceAsync(newSubscriptionId, ct); + var subItems = await LoadActiveSubItemsAsync(newSubscriptionId, ct); + + // Approval-flow invoices never re-bill the one-time onboarding fee. + decimal subtotal = newSubscription.CustomerPlan.PlanPrice + subItems.Sum(si => si.SubItemPrice); + decimal discountAmount = DiscountCalculator.ComputeDiscountAmount( + subtotal, newSubscription.DiscountType, newSubscription.DiscountValue); + decimal newRoundAmount = Math.Max(0m, subtotal - discountAmount); + + invoice.FKCustomerSubscriptionId = newSubscriptionId; + invoice.Amount = newRoundAmount + carriedOverBalance; + invoice.IsActive = true; + + AddInvoiceLineItems(customerInvoiceId, newSubscription, subItems, onboardingFee: 0m, discountAmount); + } + + public async Task> GetCarriedForwardAmountsAsync(List invoiceIds) + { + if (invoiceIds.Count == 0) + return new Dictionary(); + + return await _context.CustomerInvoiceItems + .Where(item => invoiceIds.Contains(item.FKCustomerInvoiceId) && item.ItemName == PreviousBalanceItemName) + .GroupBy(item => item.FKCustomerInvoiceId) + .Select(g => new { InvoiceId = g.Key, Total = g.Sum(item => item.ItemPrice) }) + .ToDictionaryAsync(x => x.InvoiceId, x => x.Total); + } + + public async Task GetInvoiceBySubscriptionIdAsync(Guid subscriptionId) + { + // IsActive filter: once a renewal invoice exists awaiting approval, this must keep + // resolving to the real, still-collectible prior invoice (manual payments post + // against that one) rather than the pending-approval invoice. Defaults true for + // every other invoice, so this is a no-op for subscriptions with no pending renewal. + // RolledOver is excluded too: once its balance has been carried into a newer invoice, + // it is no longer the invoice to collect against -- without this, a payment recorded + // during the approval-pending window would land on the superseded invoice while the + // new invoice's "Previous Balance" line (which already counts that same debt) never + // reflects the payment. Credit/Applied are excluded for the same reason as RolledOver + // -- a Credit invoice has a negative Amount and nothing to collect (it's owed to the + // customer, not by them), so it must never be "the invoice" RecordSubscriptionPayment + // tries to charge against. + return await _context.CustomerInvoices + .Where(i => i.FKCustomerSubscriptionId == subscriptionId + && i.Status != "Cancelled" && i.Status != "RolledOver" + && i.Status != "Credit" && i.Status != "Applied" && i.IsActive) + .OrderByDescending(i => i.CustomerInvoiceDate) + .FirstOrDefaultAsync(); + } + + public async Task GetCustomerInvoiceById(Guid customerInvoiceId) + { + return await _context.CustomerInvoices + .Include(i => i.CustomerSubscription) + .ThenInclude(cs => cs.CustomerPlan) + .Include(i => i.CustomerSubscription) + .ThenInclude(cs => cs.TenantCustomerLink) + .FirstOrDefaultAsync(i => i.CustomerInvoiceId == customerInvoiceId); + } + + public async Task> GetCustomerInvoicesByCustomerId(Guid customerId) + { + return await _context.CustomerInvoices + .Include(i => i.CustomerSubscription) + .ThenInclude(cs => cs.CustomerPlan) + .Include(i => i.CustomerSubscription) + .ThenInclude(cs => cs.TenantCustomerLink) + .Where(i => i.CustomerSubscription.FKCustomerId == customerId) + // Newest cycle first, oldest last -- ordered by the subscription's own + // RenewalDate/ExpiryDate (its true place in the renewal chain), not raw + // CustomerInvoiceDate: a backdated OnboardDate anchors that date to the + // backdated cycle, so the very first invoice can carry a date that's numerically + // *later* than several genuinely more recent renewals, putting it at the top of + // a CustomerInvoiceDate-sorted list instead of the bottom. + .OrderByDescending(i => i.CustomerSubscription.RenewalDate + ?? i.CustomerSubscription.ExpiryDate + ?? i.CustomerInvoiceDate) + .ToListAsync(); + } + + public async Task> GetCustomerInvoicesByClientId(Guid clientId) + { + // Cancelled invoices are superseded/orphaned (e.g. left behind when a subscription + // was reassigned) and must not appear on the payments screen or count toward its + // Total Billed / Outstanding totals -- same exclusion already used everywhere else + // this repo reads invoices (GetCustomerInvoicesBySubscription/GetCustomerInvoiceById). + // RolledOver invoices are excluded the same way: their balance has already been + // carried into a newer invoice, so counting both would double the outstanding total. + return await _context.CustomerInvoices + .Include(i => i.CustomerSubscription) + .ThenInclude(cs => cs.CustomerPlan) + .Include(i => i.CustomerSubscription) + .ThenInclude(cs => cs.TenantCustomerLink) + .Where(i => i.FKClientId == clientId && i.Status != "Cancelled" && i.Status != "RolledOver") + .OrderByDescending(i => i.CustomerInvoiceDate) + .ToListAsync(); + } + + public async Task UpdateCustomerInvoiceStatus(Guid customerInvoiceId, string status) + { + var customerInvoice = await _context.CustomerInvoices.FindAsync(customerInvoiceId); + + if (customerInvoice == null) + { + throw new Exception("CustomerInvoice not found"); + } + + customerInvoice.Status = status; + + return customerInvoice; + } + + public async Task CreateSupplementalInvoiceAsync(Guid subscriptionId, string itemName, string? itemDescription, decimal amount) + { + var subscription = await _context.CustomerSubscriptions + .Include(cs => cs.CustomerPlan) + .Include(cs => cs.TenantCustomerLink) + .FirstOrDefaultAsync(cs => cs.SubscriptionId == subscriptionId); + + if (subscription == null) + { + throw new Exception("Subscription not found"); + } + + var invoice = new CustomerInvoice + { + CustomerInvoiceId = Guid.NewGuid(), + FKCustomerSubscriptionId = subscriptionId, + FKClientId = subscription.CustomerPlan.FKClientId, + FKUserId = subscription.TenantCustomerLink.CentralUserId, + Amount = amount, + Status = amount == 0m ? "Paid" : "Pending", + CustomerInvoiceDate = DateTime.UtcNow + }; + _context.CustomerInvoices.Add(invoice); + + _context.CustomerInvoiceItems.Add(new CustomerInvoiceItem + { + CustomerInvoiceItemId = Guid.NewGuid(), + FKCustomerInvoiceId = invoice.CustomerInvoiceId, + ItemName = itemName, + ItemDescription = itemDescription, + ItemPrice = amount, + CreatedAt = DateTime.UtcNow + }); + + return invoice; + } + + // Plan-change settlement invoice: `amount` is the signed diff between the new plan's + // price and whatever the customer already paid toward the old one. Nets out any + // already-open credit first (e.g. a prior downgrade), same as every other invoice path. + public async Task CreatePlanChangeInvoiceAsync( + Guid subscriptionId, string itemName, decimal amount, CancellationToken ct = default) + { + var subscription = await LoadSubscriptionForInvoiceAsync(subscriptionId, ct); + + var openCredit = await ConsumeOpenCreditAsync( + subscription.TenantCustomerLink.CentralUserId, subscription.CustomerPlan.FKClientId, ct); + var finalAmount = amount - openCredit; + + // Negative = the customer now has a credit (this settlement alone, or combined with + // extra prior credit, exceeds what they owe) -- ConsumeOpenCreditAsync already marked + // the old credit rows "Applied", so this new row becomes the one open credit invoice + // going forward. Zero reuses the same "nothing left to collect" rule as every other + // invoice path. + var status = finalAmount < 0 ? "Credit" : finalAmount == 0m ? "Paid" : "Pending"; + + var invoice = new CustomerInvoice + { + CustomerInvoiceId = Guid.NewGuid(), + FKCustomerSubscriptionId = subscriptionId, + FKClientId = subscription.CustomerPlan.FKClientId, + FKUserId = subscription.TenantCustomerLink.CentralUserId, + Amount = finalAmount, + Status = status, + CustomerInvoiceDate = DateTime.UtcNow + }; + _context.CustomerInvoices.Add(invoice); + + _context.CustomerInvoiceItems.Add(new CustomerInvoiceItem + { + CustomerInvoiceItemId = Guid.NewGuid(), + FKCustomerInvoiceId = invoice.CustomerInvoiceId, + ItemName = itemName, + ItemDescription = status == "Credit" + ? "Credit from switching to a lower-priced plan mid-cycle -- applied automatically to your next invoice." + : "Balance due from switching to a higher-priced plan mid-cycle.", + ItemPrice = finalAmount, + CreatedAt = DateTime.UtcNow + }); + + return invoice; + } + + public async Task GetApprovedPaymentTotalAsync(Guid invoiceId, CancellationToken ct = default) + { + return await _context.PaymentTransactions + .AsNoTracking() + .Where(payment => + payment.FK_CustomerInvoiceId == invoiceId && + payment.IsApproved == true && + payment.IsActive == true) + .Select(payment => (decimal?)payment.PayAmount) + .SumAsync(ct) ?? 0m; + } + } +} diff --git a/Repos/CustomerNotificationRepository.cs b/Repos/CustomerNotificationRepository.cs new file mode 100644 index 0000000..6473587 --- /dev/null +++ b/Repos/CustomerNotificationRepository.cs @@ -0,0 +1,48 @@ +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class CustomerNotificationRepository : ICustomerNotificationRepository + { + private readonly CentralDbContext _context; + public CustomerNotificationRepository(CentralDbContext context) => _context = context; + + public async Task CreateAsync(CustomerNotification notification, CancellationToken ct = default) => + await _context.CustomerNotifications.AddAsync(notification, ct); + + public async Task CreateManyAsync(IEnumerable notifications, CancellationToken ct = default) => + await _context.CustomerNotifications.AddRangeAsync(notifications, ct); + + public async Task> GetByCustomerIdAsync(Guid customerId, CancellationToken ct = default) => + await _context.CustomerNotifications + .AsNoTracking() + .Where(n => n.FKCustomerId == customerId) + .OrderByDescending(n => n.CreatedAt) + .ToListAsync(ct); + + public async Task GetByIdAsync(Guid customerNotificationId, Guid customerId, CancellationToken ct = default) => + await _context.CustomerNotifications + .FirstOrDefaultAsync(n => n.CustomerNotificationId == customerNotificationId && n.FKCustomerId == customerId, ct); + + public async Task GetCustomerPushTokenAsync(Guid customerId, CancellationToken ct = default) => + await _context.Customer + .AsNoTracking() + .Where(c => c.CusId == customerId) + .Select(c => c.PushTokenId) + .FirstOrDefaultAsync(ct); + + public async Task> GetCustomerPushTokensAsync(IEnumerable customerIds, CancellationToken ct = default) + { + var ids = customerIds.Distinct().ToList(); + if (ids.Count == 0) return new List(); + + return await _context.Customer + .AsNoTracking() + .Where(c => ids.Contains(c.CusId) && c.PushTokenId != null) + .Select(c => c.PushTokenId!) + .ToListAsync(ct); + } + } +} diff --git a/Repos/CustomerPlanRepository.cs b/Repos/CustomerPlanRepository.cs new file mode 100644 index 0000000..70f61a0 --- /dev/null +++ b/Repos/CustomerPlanRepository.cs @@ -0,0 +1,148 @@ +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class CustomerPlanRepository : ICustomerPlanRepository + { + // Client is central-bucket -- CustomerPlan.Client can't be a same-context Include once + // central/tenant are physically separate (see TenantDbContext.OnModelCreating). Loaded + // separately here and attached in memory, same convention as StaffRepository.User. + private readonly TenantDbContext _context; + private readonly CentralDbContext _central; + + public CustomerPlanRepository(TenantDbContext context, CentralDbContext central) + { + _context = context; + _central = central; + } + + private async Task AttachClientsAsync(IEnumerable plans) + { + var clientIds = plans.Select(p => p.FKClientId).Distinct().ToList(); + if (clientIds.Count == 0) return; + + var clients = await _central.Client + .AsNoTracking() + .Where(c => clientIds.Contains(c.ClientId)) + .ToDictionaryAsync(c => c.ClientId); + + foreach (var plan in plans) + { + if (clients.TryGetValue(plan.FKClientId, out var client)) + plan.Client = client; + } + } + + public async Task> GetAllCustomerPlans(bool includeInactive = false, Guid? clientId = null) + { + var query = _context.CustomerPlans.AsQueryable(); + + if (clientId.HasValue) + { + query = query.Where(cp => cp.FKClientId == clientId.Value); + } + + if (!includeInactive) + { + query = query.Where(cp => cp.IsActive); + } + + var plans = await query + .OrderBy(cp => cp.PlanName) + .ToListAsync(); + + await AttachClientsAsync(plans); + return plans; + } + + public async Task GetCustomerPlanById(Guid customerPlanId) + { + var plan = await _context.CustomerPlans + .FirstOrDefaultAsync(cp => cp.CustomerPlanId == customerPlanId); + if (plan != null) + await AttachClientsAsync(new[] { plan }); + return plan; + } + + public async Task CreateCustomerPlan(CustomerPlan customerPlan) + { + customerPlan.CustomerPlanId = Guid.NewGuid(); + customerPlan.CreatedAt = DateTime.UtcNow; + customerPlan.IsActive = true; + + _context.CustomerPlans.Add(customerPlan); + + return customerPlan; + } + + public async Task UpdateCustomerPlan(CustomerPlan customerPlan) + { + var existingPlan = await _context.CustomerPlans.FindAsync(customerPlan.CustomerPlanId); + + if (existingPlan == null) + { + throw new Exception("Customer Plan not found"); + } + + // The service merges provided keys onto the loaded entity and validates + // the combined configuration, so this is a direct full assignment. + existingPlan.PlanName = customerPlan.PlanName; + existingPlan.PlanDescription = customerPlan.PlanDescription; + existingPlan.PlanPrice = customerPlan.PlanPrice; + existingPlan.DurationInDays = customerPlan.DurationInDays; + existingPlan.PlanScope = customerPlan.PlanScope; + existingPlan.MemberLimit = customerPlan.MemberLimit; + existingPlan.DurationMode = customerPlan.DurationMode; + existingPlan.ValidFrom = customerPlan.ValidFrom; + existingPlan.ValidTo = customerPlan.ValidTo; + existingPlan.AllowSplitPayments = customerPlan.AllowSplitPayments; + existingPlan.InstallmentCount = customerPlan.InstallmentCount; + existingPlan.SessionsPerCycle = customerPlan.SessionsPerCycle; + existingPlan.SessionRolloverPolicy = customerPlan.SessionRolloverPolicy; + existingPlan.RolloverGraceDays = customerPlan.RolloverGraceDays; + existingPlan.BillingTiming = customerPlan.BillingTiming; + existingPlan.IsActive = customerPlan.IsActive; + + return existingPlan; + } + + public async Task SetCustomerPlanActive(Guid customerPlanId, Guid clientId, bool isActive) + { + var plan = await _context.CustomerPlans + .FirstOrDefaultAsync(cp => cp.CustomerPlanId == customerPlanId && cp.FKClientId == clientId); + + if (plan == null) + return null; + + plan.IsActive = isActive; + + await AttachClientsAsync(new[] { plan }); + return plan; + } + + public async Task DeleteCustomerPlan(Guid customerPlanId, bool softDelete = true) + { + var customerPlan = await _context.CustomerPlans.FindAsync(customerPlanId); + + if (customerPlan == null) + { + return false; + } + + if (softDelete) + { + // Soft delete: just mark as inactive + customerPlan.IsActive = false; + } + else + { + // Hard delete: remove from database + _context.CustomerPlans.Remove(customerPlan); + } + + return true; + } + } +} diff --git a/Repos/CustomerRepository.cs b/Repos/CustomerRepository.cs new file mode 100644 index 0000000..0b0cf5a --- /dev/null +++ b/Repos/CustomerRepository.cs @@ -0,0 +1,1035 @@ +using Fitness_core_BE.DTOs.Meal; +using Fitness_core_BE.DTOs.Workout; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; +using Fitness_core_BE.Exceptions; + +namespace Fitness_core_BE.Repos +{ + public class CustomerRepository : ICustomerRepository + { + // Tenant-scoped operational data (memberships, plan assignments, cards, ...). + private readonly TenantDbContext _context; + // Central identity data (Customer, health/targets, admin templates, ClientBusiness). + private readonly CentralDbContext _central; + + public CustomerRepository(TenantDbContext context, CentralDbContext central) + { + _context = context; + _central = central; + } + + public async Task GetCustomerById(Guid customerId) + { + return await _central.Customer + .Include(c => c.User) + .FirstOrDefaultAsync(c => c.CusId == customerId && c.IsActive); + } + + public async Task GetCustomerByIdentifier(string identifier) + { + if (string.IsNullOrWhiteSpace(identifier)) + return null; + + var id = identifier.Trim(); + return await _central.Customer + .Include(c => c.User) + .FirstOrDefaultAsync(c => + c.PrimaryPH == id || c.SecondaryPH == id || c.Email == id || + (c.User != null && (c.User.PhoneNumber == id || c.User.Email == id))); + } + + public async Task CreateCustomer(Customer customer) + { + if (customer.User != null) + { + _central.Users.Add(customer.User); + } + else + { + var userExists = await _central.Users.AnyAsync(u => u.UserId == customer.FKUserId); + if (!userExists) + { + throw new Exception("User not found"); + } + } + + _central.Customer.Add(customer); + + return customer; + } + + // Every tenant-side customer FK (CustomerSubscriptions, SubscriptionMembers, cards, + // attendance, ...) targets TenantCustomerLink, not the central Customer table, so the + // anchor row has to exist in this tenant DB before any of them can be inserted. The + // link id IS the central Customer.CusId (see Models/TenantCustomerLink.cs), so this is + // a pure upsert keyed on that id -- no surrogate key to allocate. + public async Task EnsureTenantCustomerLink(Customer customer) + { + var userId = customer.User?.UserId ?? customer.FKUserId; + + // Local-first: on the create path the Customer/User rows are still pending in the + // central context, and the link row may itself already be pending from an earlier + // call in this same unit of work -- FindAsync checks the change tracker before + // hitting the DB, so a second call in one request updates rather than re-adds. + var link = await _context.TenantCustomerLink.FindAsync(customer.CusId); + + if (link == null) + { + _context.TenantCustomerLink.Add(new TenantCustomerLink + { + TenantCustomerLinkId = customer.CusId, + CentralUserId = userId, + DisplayName = customer.CusName, + PrimaryPhoneCached = customer.PrimaryPH, + EmailCached = customer.Email, + IsActiveAtThisClient = true, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }); + return; + } + + // Re-enrolment at this gym: revive the link and refresh the cached display fields + // so list/report queries that read them don't serve stale values. + link.CentralUserId = userId; + link.DisplayName = customer.CusName; + link.PrimaryPhoneCached = customer.PrimaryPH; + link.EmailCached = customer.Email; + link.IsActiveAtThisClient = true; + link.UpdatedAt = DateTime.UtcNow; + } + + // Own-gym duplicate check for customer creation: this tenant's own TenantCustomerLink + // table already holds exactly this gym's active customers, so no clientId is needed. + public async Task FindActiveLinkByIdentifierAsync(string identifier) + { + if (string.IsNullOrWhiteSpace(identifier)) + return null; + + var id = identifier.Trim(); + return await _context.TenantCustomerLink + .FirstOrDefaultAsync(l => + l.IsActiveAtThisClient && + (l.PrimaryPhoneCached == id || l.EmailCached == id)); + } + + + public async Task UpdateCustomer(Guid customerId, Customer updatedCustomer) + { + // updatedCustomer is the same tracked instance GetCustomerById(customerId) returned + // (Customer + Include(User)), so any field the caller set directly on it — including + // updatedCustomer.User.* — is already picked up by SaveChanges via change tracking. + _central.Customer.Update(updatedCustomer); + + return updatedCustomer; + } + + // Self-service full-account delete (legacy generic-dispatch path, CustomerService. + // DeleteCustomer): unlike the client-portal delete below, this really does deactivate + // the shared central identity -- the customer themselves is asking to leave the + // platform entirely, not just one gym. + public async Task DeleteCustomer(Guid customerId) + { + var customer = await _central.Customer.FindAsync(customerId); + + if (customer == null) + { + return false; + } + customer.IsActive = false; + + // A deleted customer must no longer retain an active membership. CustomerPlan is + // a reusable plan definition, so do not deactivate/delete it; only deactivate the + // customer's subscription and its active related records. + var ownedSubscriptionIds = await _context.CustomerSubscriptions + .Where(subscription => subscription.FKCustomerId == customerId) + .Select(subscription => subscription.SubscriptionId) + .ToListAsync(); + + var ownedSubscriptions = await _context.CustomerSubscriptions + .Where(subscription => ownedSubscriptionIds.Contains(subscription.SubscriptionId) && + subscription.IsActive) + .ToListAsync(); + foreach (var subscription in ownedSubscriptions) + subscription.IsActive = false; + + var membersToDeactivate = await _context.SubscriptionMembers + .Where(member => member.IsActive && + (member.FKCustomerId == customerId || + ownedSubscriptionIds.Contains(member.FKSubscriptionId))) + .ToListAsync(); + foreach (var member in membersToDeactivate) + member.IsActive = false; + + var subItemsToDeactivate = await _context.CustomerSubItems + .Where(item => item.IsActive && + ownedSubscriptionIds.Contains(item.FKCustomerSubscriptionId)) + .ToListAsync(); + foreach (var item in subItemsToDeactivate) + item.IsActive = false; + + var installmentsToDeactivate = await _context.SubscriptionInstallments + .Where(installment => installment.IsActive && + ownedSubscriptionIds.Contains(installment.FKSubscriptionId)) + .ToListAsync(); + foreach (var installment in installmentsToDeactivate) + installment.IsActive = false; + + // A deleted customer must also drop off their assigned workout/meal plans. If this + // customer is ever re-added later (see CreateCustomerWithPlans reactivation), these + // rows stay inactive — re-adding assigns fresh plans rather than reviving old ones. + var workoutsToDeactivate = await _context.CustomerWorkouts + .Where(cw => cw.IsActive && cw.FKCustomerId == customerId) + .ToListAsync(); + foreach (var workout in workoutsToDeactivate) + workout.IsActive = false; + + var mealsToDeactivate = await _context.CustomerMeals + .Where(cm => cm.IsActive && cm.FKCustomerId == customerId) + .ToListAsync(); + foreach (var meal in mealsToDeactivate) + meal.IsActive = false; + + return true; + } + + // Client-initiated delete: only deactivates this customer's subscriptions/plan + // assignments at the calling client's gym — the shared central Customer.IsActive flag + // is never touched here, since the same person may still be an active member elsewhere. + public async Task DeactivateCustomerForClient(Guid customerId, Guid clientId) + { + var customerExists = await _central.Customer.AnyAsync(c => c.CusId == customerId); + if (!customerExists) + return false; + + var ownedSubscriptionIds = await _context.CustomerSubscriptions + .Where(subscription => subscription.FKCustomerId == customerId && + subscription.CustomerPlan.FKClientId == clientId) + .Select(subscription => subscription.SubscriptionId) + .ToListAsync(); + + var ownedSubscriptions = await _context.CustomerSubscriptions + .Where(subscription => ownedSubscriptionIds.Contains(subscription.SubscriptionId) && + subscription.IsActive) + .ToListAsync(); + foreach (var subscription in ownedSubscriptions) + subscription.IsActive = false; + + var membersToDeactivate = await _context.SubscriptionMembers + .Where(member => member.IsActive && ownedSubscriptionIds.Contains(member.FKSubscriptionId)) + .ToListAsync(); + foreach (var member in membersToDeactivate) + member.IsActive = false; + + var subItemsToDeactivate = await _context.CustomerSubItems + .Where(item => item.IsActive && + ownedSubscriptionIds.Contains(item.FKCustomerSubscriptionId)) + .ToListAsync(); + foreach (var item in subItemsToDeactivate) + item.IsActive = false; + + var installmentsToDeactivate = await _context.SubscriptionInstallments + .Where(installment => installment.IsActive && + ownedSubscriptionIds.Contains(installment.FKSubscriptionId)) + .ToListAsync(); + foreach (var installment in installmentsToDeactivate) + installment.IsActive = false; + + // Only drop workout/meal assignments tied to this client's own plan, or to a + // shared admin template (FKClientId == null) — never another client's custom plan. + // The plan may live in either bucket (templates are central), so the eligible ids + // are gathered from both and matched on the scalar FK. + var eligibleWorkoutPlanIds = await _context.WorkoutPlans + .Where(wp => wp.FKClientId == clientId || wp.FKClientId == null) + .Select(wp => wp.WorkoutPlanId) + .ToListAsync(); + eligibleWorkoutPlanIds.AddRange(await _central.WorkoutPlans + .Where(wp => wp.FKClientId == clientId || wp.FKClientId == null) + .Select(wp => wp.WorkoutPlanId) + .ToListAsync()); + + var workoutsToDeactivate = await _context.CustomerWorkouts + .Where(cw => cw.IsActive && cw.FKCustomerId == customerId && + eligibleWorkoutPlanIds.Contains(cw.FKWorkoutPlanId)) + .ToListAsync(); + foreach (var workout in workoutsToDeactivate) + workout.IsActive = false; + + var eligibleMealPlanIds = await _context.MealPlans + .Where(mp => mp.FKClientId == clientId || mp.FKClientId == null) + .Select(mp => mp.MealPlanId) + .ToListAsync(); + eligibleMealPlanIds.AddRange(await _central.MealPlans + .Where(mp => mp.FKClientId == clientId || mp.FKClientId == null) + .Select(mp => mp.MealPlanId) + .ToListAsync()); + + var mealsToDeactivate = await _context.CustomerMeals + .Where(cm => cm.IsActive && cm.FKCustomerId == customerId && + eligibleMealPlanIds.Contains(cm.FKMealPlanId)) + .ToListAsync(); + foreach (var meal in mealsToDeactivate) + meal.IsActive = false; + + return true; + } + + public async Task GetCustomerClientId(Guid customerId) + { + CustomerSubscription? cusSub = await _context.CustomerSubscriptions + .Include(CustomerPlan => CustomerPlan.CustomerPlan) + .Where(cs => cs.FKCustomerId == customerId && cs.IsActive) + .FirstOrDefaultAsync(); + + if (cusSub == null) + return null; + + return cusSub.CustomerPlan.FKClientId; + } + + // A MealPlanId/WorkoutPlanId referenced from a tenant CustomerMeal/CustomerWorkout row + // could be an admin template (central) or this gym's own custom plan (tenant) -- try + // central first, fall back to tenant. + private async Task ResolveMealPlanAsync(Guid mealPlanId, bool includeItems = false) + { + IQueryable centralQuery = _central.MealPlans.AsNoTracking(); + if (includeItems) centralQuery = centralQuery.Include(mp => mp.MealItems); + var central = await centralQuery.FirstOrDefaultAsync(mp => mp.MealPlanId == mealPlanId); + if (central != null) return central; + + IQueryable tenantQuery = _context.MealPlans.AsNoTracking(); + if (includeItems) tenantQuery = tenantQuery.Include(mp => mp.MealItems); + return await tenantQuery.FirstOrDefaultAsync(mp => mp.MealPlanId == mealPlanId); + } + + private async Task ResolveWorkoutPlanAsync(Guid workoutPlanId, bool includeItems = false) + { + IQueryable centralQuery = _central.WorkoutPlans.AsNoTracking(); + if (includeItems) centralQuery = centralQuery.Include(wp => wp.WorkoutItems).ThenInclude(wi => wi.WorkoutExercises); + var central = await centralQuery.FirstOrDefaultAsync(wp => wp.WorkoutPlanId == workoutPlanId); + if (central != null) return central; + + IQueryable tenantQuery = _context.WorkoutPlans.AsNoTracking(); + if (includeItems) tenantQuery = tenantQuery.Include(wp => wp.WorkoutItems).ThenInclude(wi => wi.WorkoutExercises); + return await tenantQuery.FirstOrDefaultAsync(wp => wp.WorkoutPlanId == workoutPlanId); + } + + public async Task GetActiveMealPlanAsync(Guid? clientId = null, Guid? cusId = null) + { + if (clientId == null && cusId == null) + throw new ArgumentException("Either clientId or cusId must be provided."); + + Guid? mealPlanId; + if (clientId != null) + { + // The plan may be a central template or this gym's own, so match on the + // scalar id against plan ids gathered from both buckets. + var clientMealPlanIds = await _context.MealPlans + .Where(mp => mp.FKClientId == clientId) + .Select(mp => mp.MealPlanId) + .ToListAsync(); + clientMealPlanIds.AddRange(await _central.MealPlans + .Where(mp => mp.FKClientId == clientId) + .Select(mp => mp.MealPlanId) + .ToListAsync()); + + mealPlanId = await _context.CustomerMeals + .Where(cm => clientMealPlanIds.Contains(cm.FKMealPlanId) && cm.IsActive && cm.FKCustomerId == cusId) + .Select(cm => (Guid?)cm.FKMealPlanId) + .FirstOrDefaultAsync(); + } + else + { + mealPlanId = await _context.CustomerMeals + .Where(cm => cm.FKCustomerId == cusId && cm.IsActive) + .Select(cm => (Guid?)cm.FKMealPlanId) + .FirstOrDefaultAsync(); + } + + return mealPlanId.HasValue ? await ResolveMealPlanAsync(mealPlanId.Value) : null; + } + + // Shared plan-detail assembly for the meal-plan read paths below: resolves each plan + // from whichever bucket it lives in, then batches this tenant's CustomerMeal + // assignment rows for those plans (assignment rows are always tenant-local, even when + // the plan itself is a central template). + private async Task> BuildMealPlanDetailsAsync( + IReadOnlyCollection mealPlanIds, Guid? assignedCustomerFilter) + { + if (mealPlanIds.Count == 0) + return new List(); + + var centralPlans = await _central.MealPlans + .AsNoTracking() + .Include(mp => mp.MealCategory) + .Include(mp => mp.MealItems) + .Where(mp => mealPlanIds.Contains(mp.MealPlanId) && mp.IsActive) + .ToListAsync(); + var foundIds = centralPlans.Select(mp => mp.MealPlanId).ToHashSet(); + var missingIds = mealPlanIds.Where(id => !foundIds.Contains(id)).ToList(); + var tenantPlans = missingIds.Count == 0 + ? new List() + : await _context.MealPlans + .AsNoTracking() + .Include(mp => mp.MealCategory) + .Include(mp => mp.MealItems) + .Where(mp => missingIds.Contains(mp.MealPlanId) && mp.IsActive) + .ToListAsync(); + var plans = centralPlans.Concat(tenantPlans).ToList(); + if (plans.Count == 0) + return new List(); + + var assignmentQuery = _context.CustomerMeals + .AsNoTracking() + .Include(cm => cm.TenantCustomerLink) + .Where(cm => plans.Select(p => p.MealPlanId).Contains(cm.FKMealPlanId) && cm.IsActive); + if (assignedCustomerFilter.HasValue) + assignmentQuery = assignmentQuery.Where(cm => cm.FKCustomerId == assignedCustomerFilter.Value); + var assignments = await assignmentQuery.ToListAsync(); + var assignmentsByPlan = assignments.GroupBy(a => a.FKMealPlanId).ToDictionary(g => g.Key, g => g.ToList()); + + return plans.Select(mealPlan => + { + var mealItems = mealPlan.MealItems + .Where(mi => mi.IsActive) + .Select(mi => new MealDto + { + MealId = mi.MealId, + Name = mi.Name, + Description = mi.Description, + Calories = mi.Calories, + Protein = mi.Protein, + Carbohydrates = mi.Carbohydrates, + Fats = mi.Fats, + ServingSize = mi.ServingSize, + MealItems = mi.MealItems, + PreparationNotes = mi.PreparationNotes, + MealPlanId = mi.FKMealPlanId, + IsActive = mi.IsActive, + CreateDate = mi.CreateDate + }) + .ToList(); + + assignmentsByPlan.TryGetValue(mealPlan.MealPlanId, out var planAssignments); + + return new MealPlanDetailDto + { + MealPlanId = mealPlan.MealPlanId, + Name = mealPlan.Name, + Description = mealPlan.Description, + IsTemplate = mealPlan.IsTemplate, + MealCategoryId = mealPlan.FKMealCategoryId, + MealCategoryName = mealPlan.MealCategory != null ? mealPlan.MealCategory.Name : "", + ClientId = mealPlan.FKClientId, + ClientName = null, + IsActive = mealPlan.IsActive, + CreateDate = mealPlan.CreateDate, + MealItems = mealItems, + AssignedCustomers = (planAssignments ?? new List()) + .Select(cm => new CustomerMealDto + { + CustomerMealId = cm.CustomerMealId, + CustomerId = cm.FKCustomerId, + CustomerName = cm.TenantCustomerLink?.DisplayName, + MealPlanId = cm.FKMealPlanId, + IsActive = cm.IsActive, + IsFollowing = cm.IsFollowing, + AssignedDate = cm.AssignedDate, + CompletedDate = cm.CompletedDate, + Notes = cm.Notes + }).ToList(), + TotalCalories = mealItems.Sum(mi => mi.Calories ?? 0), + TotalProtein = mealItems.Sum(mi => mi.Protein ?? 0), + TotalCarbohydrates = mealItems.Sum(mi => mi.Carbohydrates ?? 0), + TotalFats = mealItems.Sum(mi => mi.Fats ?? 0) + }; + }).ToList(); + } + + public async Task> GetFollowedMealPlansByCustomerIdAsync(Guid customerId) + { + var planIds = await _context.CustomerMeals + .AsNoTracking() + .Where(cm => cm.FKCustomerId == customerId && cm.IsActive && cm.IsFollowing) + .OrderByDescending(cm => cm.AssignedDate) + .Select(cm => cm.FKMealPlanId) + .Distinct() + .ToListAsync(); + + return await BuildMealPlanDetailsAsync(planIds, customerId); + } + + // All of the customer's own AI-generated meal plans, whether currently followed or + // not — i.e. every non-template plan this customer has ever created/been assigned via + // CreateCustomerMealPlan. Distinct from admin/client-authored templates (IsTemplate=true). + public async Task> GetMyMealPlansByCustomerIdAsync(Guid customerId) + { + var planIds = await _context.CustomerMeals + .AsNoTracking() + .Where(cm => cm.FKCustomerId == customerId && cm.IsActive) + .OrderByDescending(cm => cm.AssignedDate) + .Select(cm => cm.FKMealPlanId) + .Distinct() + .ToListAsync(); + + var details = await BuildMealPlanDetailsAsync(planIds, customerId); + return details.Where(d => !d.IsTemplate).ToList(); + } + + public async Task GetMealPlanByIdForCustomerAsync(Guid mealPlanId) + { + var details = await BuildMealPlanDetailsAsync(new[] { mealPlanId }, assignedCustomerFilter: null); + return details.FirstOrDefault(); + } + + // Projects a customer's completed WorkoutExerciseIds, keyed by CustomerWorkoutId, for + // the given set of CustomerWorkout rows — used to stamp IsCompleted on exercise DTOs. + private async Task>> GetCompletedExerciseIdsByCustomerWorkoutAsync(IEnumerable customerWorkoutIds) + { + var ids = customerWorkoutIds.Distinct().ToList(); + if (ids.Count == 0) return new Dictionary>(); + + var completions = await _context.CustomerWorkoutExerciseCompletions + .AsNoTracking() + .Where(c => ids.Contains(c.FKCustomerWorkoutId)) + .Select(c => new { c.FKCustomerWorkoutId, c.FKWorkoutExerciseId }) + .ToListAsync(); + + return completions + .GroupBy(c => c.FKCustomerWorkoutId) + .ToDictionary(g => g.Key, g => g.Select(c => c.FKWorkoutExerciseId).ToHashSet()); + } + + private static List MapWorkoutItemsWithExercises(IEnumerable items, HashSet? completedExerciseIds, bool onlyActive = true) + { + var completed = completedExerciseIds ?? new HashSet(); + var filtered = onlyActive ? items.Where(wi => wi.IsActive) : items; + + return filtered.Select(wi => + { + var exerciseDtos = wi.WorkoutExercises + .Where(we => we.IsActive) + .OrderBy(we => we.SortOrder) + .Select(we => new WorkoutExerciseDto + { + WorkoutExerciseId = we.WorkoutExerciseId, + Name = we.Name, + MuscleGroup = we.MuscleGroup, + Sets = we.Sets, + Reps = we.Reps, + Description = we.Description, + BreathIn = we.BreathIn, + BreathOut = we.BreathOut, + VideoUrl = we.VideoUrl, + SortOrder = we.SortOrder, + IsCompleted = completed.Contains(we.WorkoutExerciseId) + }) + .ToList(); + + return new WorkoutItemDto + { + WorkoutItemId = wi.WorkoutItemId, + ExerciseDay = wi.ExerciseDay, + Description = wi.Description, + Exercises = wi.Exercises, + HowToDo = wi.HowToDo, + DoURL = wi.DoURL, + Duration = wi.Duration, + WormUp = wi.WormUp, + Cooldown = wi.Cooldown, + WorkoutPlanId = wi.FKWorkoutPlanId, + IsActive = wi.IsActive, + CreateDate = wi.CreateDate, + UpdatedAt = wi.UpdatedAt, + WorkoutExercises = exerciseDtos, + TotalExercises = exerciseDtos.Count, + CompletedExercises = exerciseDtos.Count(e => e.IsCompleted), + IsCompleted = exerciseDtos.Count > 0 && exerciseDtos.All(e => e.IsCompleted) + }; + }).ToList(); + } + + // Shared plan-detail assembly for the workout-plan read paths below -- same + // central-or-tenant resolution + tenant-only assignment batching as meal plans. + private async Task> BuildWorkoutPlanDetailsAsync( + IReadOnlyCollection workoutPlanIds, Guid? assignedCustomerFilter) + { + if (workoutPlanIds.Count == 0) + return new List(); + + var centralPlans = await _central.WorkoutPlans + .AsNoTracking() + .Include(wp => wp.WorkoutCategory) + .Include(wp => wp.WorkoutItems).ThenInclude(wi => wi.WorkoutExercises) + .Where(wp => workoutPlanIds.Contains(wp.WorkoutPlanId) && wp.IsActive) + .ToListAsync(); + var foundIds = centralPlans.Select(wp => wp.WorkoutPlanId).ToHashSet(); + var missingIds = workoutPlanIds.Where(id => !foundIds.Contains(id)).ToList(); + var tenantPlans = missingIds.Count == 0 + ? new List() + : await _context.WorkoutPlans + .AsNoTracking() + .Include(wp => wp.WorkoutCategory) + .Include(wp => wp.WorkoutItems).ThenInclude(wi => wi.WorkoutExercises) + .Where(wp => missingIds.Contains(wp.WorkoutPlanId) && wp.IsActive) + .ToListAsync(); + var plans = centralPlans.Concat(tenantPlans).ToList(); + if (plans.Count == 0) + return new List(); + + var assignmentQuery = _context.CustomerWorkouts + .AsNoTracking() + .Include(cw => cw.TenantCustomerLink) + .Where(cw => plans.Select(p => p.WorkoutPlanId).Contains(cw.FKWorkoutPlanId) && cw.IsActive); + if (assignedCustomerFilter.HasValue) + assignmentQuery = assignmentQuery.Where(cw => cw.FKCustomerId == assignedCustomerFilter.Value); + var assignments = await assignmentQuery.ToListAsync(); + var assignmentsByPlan = assignments.GroupBy(a => a.FKWorkoutPlanId).ToDictionary(g => g.Key, g => g.ToList()); + + var completedByWorkout = await GetCompletedExerciseIdsByCustomerWorkoutAsync( + assignments.Where(a => !assignedCustomerFilter.HasValue || a.FKCustomerId == assignedCustomerFilter.Value) + .Select(a => a.CustomerWorkoutId)); + + return plans.Select(workoutPlan => + { + assignmentsByPlan.TryGetValue(workoutPlan.WorkoutPlanId, out var planAssignments); + planAssignments ??= new List(); + + var focusWorkoutId = assignedCustomerFilter.HasValue + ? planAssignments.FirstOrDefault(a => a.FKCustomerId == assignedCustomerFilter.Value)?.CustomerWorkoutId + : null; + completedByWorkout.TryGetValue(focusWorkoutId ?? Guid.Empty, out var completedExerciseIds); + + return new WorkoutPlanDetailDto + { + WorkoutPlanId = workoutPlan.WorkoutPlanId, + Name = workoutPlan.WorkoutPlanName, + Description = workoutPlan.Description, + IsTemplate = workoutPlan.IsTemplate, + WorkoutCategoryId = workoutPlan.FKWorkoutCategoryId, + WorkoutCategoryName = workoutPlan.WorkoutCategory != null ? workoutPlan.WorkoutCategory.WorkoutCategoryName : "", + ClientId = workoutPlan.FKClientId, + ClientName = null, + IsActive = workoutPlan.IsActive, + CreateDate = workoutPlan.CreateDate, + WorkoutItems = MapWorkoutItemsWithExercises(workoutPlan.WorkoutItems, completedExerciseIds, onlyActive: assignedCustomerFilter.HasValue), + AssignedCustomers = planAssignments + .Select(cw => new CustomerWorkoutDto + { + CustomerWorkoutId = cw.CustomerWorkoutId, + CustomerId = cw.FKCustomerId, + CustomerName = cw.TenantCustomerLink?.DisplayName, + WorkoutPlanId = cw.FKWorkoutPlanId, + WorkoutPlanName = workoutPlan.WorkoutPlanName, + IsActive = cw.IsActive, + IsFollowing = cw.IsFollowing, + AssignedDate = cw.AssignedDate, + CompletedDate = cw.CompletedDate, + Notes = cw.Notes + }).ToList() + }; + }).ToList(); + } + + public async Task> GetFollowedWorkoutPlansByCustomerIdAsync(Guid customerId) + { + var planIds = await _context.CustomerWorkouts + .AsNoTracking() + .Where(cw => cw.FKCustomerId == customerId && cw.IsActive && cw.IsFollowing) + .OrderByDescending(cw => cw.AssignedDate) + .Select(cw => cw.FKWorkoutPlanId) + .Distinct() + .ToListAsync(); + + return await BuildWorkoutPlanDetailsAsync(planIds, customerId); + } + + // All of the customer's own AI-generated workout plans, whether currently followed or + // not. Distinct from admin/client-authored templates (IsTemplate=true). + public async Task> GetMyWorkoutPlansByCustomerIdAsync(Guid customerId) + { + var planIds = await _context.CustomerWorkouts + .AsNoTracking() + .Where(cw => cw.FKCustomerId == customerId && cw.IsActive) + .OrderByDescending(cw => cw.AssignedDate) + .Select(cw => cw.FKWorkoutPlanId) + .Distinct() + .ToListAsync(); + + var details = await BuildWorkoutPlanDetailsAsync(planIds, customerId); + return details.Where(d => !d.IsTemplate).ToList(); + } + + public async Task GetWorkoutPlanByIdForCustomerAsync(Guid workoutPlanId, Guid customerId) + { + var details = await BuildWorkoutPlanDetailsAsync(new[] { workoutPlanId }, customerId); + return details.FirstOrDefault(); + } + + public async Task GetActiveCustomerWorkoutAsync(Guid customerId, Guid workoutPlanId) + { + return await _context.CustomerWorkouts + .FirstOrDefaultAsync(cw => cw.FKCustomerId == customerId && cw.FKWorkoutPlanId == workoutPlanId && cw.IsActive); + } + + public async Task SetExerciseCompletionAsync(Guid customerWorkoutId, Guid workoutExerciseId, bool completed) + { + var existing = await _context.CustomerWorkoutExerciseCompletions + .FirstOrDefaultAsync(c => c.FKCustomerWorkoutId == customerWorkoutId && c.FKWorkoutExerciseId == workoutExerciseId); + + if (completed) + { + if (existing == null) + { + await _context.CustomerWorkoutExerciseCompletions.AddAsync(new CustomerWorkoutExerciseCompletion + { + CustomerWorkoutExerciseCompletionId = Guid.NewGuid(), + FKCustomerWorkoutId = customerWorkoutId, + FKWorkoutExerciseId = workoutExerciseId, + CompletedDate = DateTime.UtcNow + }); + } + } + else if (existing != null) + { + _context.CustomerWorkoutExerciseCompletions.Remove(existing); + } + } + + public async Task GetActiveWorkOutAsync(Guid? clientId = null, Guid? cusId = null) + { + if (clientId == null && cusId == null) + throw new ArgumentException("Either clientId or cusId must be provided."); + + Guid? workoutPlanId; + if (clientId != null) + { + // The plan may be a central template or this gym's own, so match on the + // scalar id against plan ids gathered from both buckets. + var clientPlanIds = await _context.WorkoutPlans + .Where(wp => wp.FKClientId == clientId) + .Select(wp => wp.WorkoutPlanId) + .ToListAsync(); + clientPlanIds.AddRange(await _central.WorkoutPlans + .Where(wp => wp.FKClientId == clientId) + .Select(wp => wp.WorkoutPlanId) + .ToListAsync()); + + workoutPlanId = await _context.CustomerWorkouts + .Where(cm => clientPlanIds.Contains(cm.FKWorkoutPlanId) && cm.IsActive && cm.FKCustomerId == cusId) + .Select(cm => (Guid?)cm.FKWorkoutPlanId) + .FirstOrDefaultAsync(); + } + else + { + workoutPlanId = await _context.CustomerWorkouts + .Where(cm => cm.FKCustomerId == cusId && cm.IsActive) + .Select(cm => (Guid?)cm.FKWorkoutPlanId) + .FirstOrDefaultAsync(); + } + + return workoutPlanId.HasValue ? await ResolveWorkoutPlanAsync(workoutPlanId.Value) : null; + } + + public async Task FollowMealPlan(Guid customerId, Guid mealPlanId) + { + var currentFollow = await _context.CustomerMeals + .FirstOrDefaultAsync(cm => cm.FKCustomerId == customerId && cm.IsFollowing); + + if (currentFollow != null && currentFollow.FKMealPlanId == mealPlanId) + { + return new { message = "Already following this meal plan" }; + } + + if (currentFollow != null) + { + currentFollow.IsFollowing = false; + _context.CustomerMeals.Update(currentFollow); + } + + var existingFollow = await _context.CustomerMeals + .FirstOrDefaultAsync(cm => cm.FKCustomerId == customerId && cm.FKMealPlanId == mealPlanId); + + if (existingFollow != null) + { + existingFollow.IsFollowing = true; + existingFollow.IsActive = true; + + _context.CustomerMeals.Update(existingFollow); + } + else + { + var newFollow = new CustomerMeal + { + FKCustomerId = customerId, + FKMealPlanId = mealPlanId, + IsFollowing = true, + IsActive = true + }; + + _context.CustomerMeals.Add(newFollow); + } + + return new { success = true, message = "Now following the selected meal plan" }; + } + + public async Task FollowWorkoutPlan(Guid customerId, Guid workOutPlanId) + + { + var currentFollow = await _context.CustomerWorkouts + .FirstOrDefaultAsync(cm => cm.FKCustomerId == customerId && cm.IsFollowing); + + if (currentFollow != null && currentFollow.FKWorkoutPlanId == workOutPlanId) + { + return new { message = "Already following this workout plan" }; + } + + if (currentFollow != null) + { + currentFollow.IsFollowing = false; + _context.CustomerWorkouts.Update(currentFollow); + } + + var existingFollow = await _context.CustomerWorkouts + .FirstOrDefaultAsync(cm => cm.FKCustomerId == customerId && cm.FKWorkoutPlanId == workOutPlanId); + + if (existingFollow != null) + { + existingFollow.IsFollowing = true; + existingFollow.IsActive = true; + + _context.CustomerWorkouts.Update(existingFollow); + } + else + { + var newFollow = new CustomerWorkout + { + FKCustomerId = customerId, + FKWorkoutPlanId = workOutPlanId, + IsFollowing = true, + IsActive = true + }; + + _context.CustomerWorkouts.Add(newFollow); + } + + return new { success = true, message = "Now following the selected workout plan" }; + } + + + + public async Task> GetAllClentShopsAsync() + { + return await _central.ClientBusiness + .Where(x => x.Latitude != null && x.Longitude != null) + .ToListAsync(); + } + + public async Task GetClientBizDataAsync(Guid ClientBizId) + { + var biz = await _central.ClientBusiness + .Include(c => c.Client) + .Where(c => c.Bid == ClientBizId && c.IsActive == true) + .FirstOrDefaultAsync(); + + if (biz == null) + throw new NotFoundException($"{ClientBizId} business not found"); + + return biz; + } + + public async Task GetCusHealthRec(Guid cusID) + { + CustomerHealth? customerHealth = await _central.CustomerHealth + .Where(ch => ch.FKCusId == cusID && ch.IsActive == true) + .FirstOrDefaultAsync(); + + if (customerHealth == null) + return null; + + return customerHealth; + } + + public async Task> GetCusProgressRec(Guid cusID) + { + var customerTargets = await _central.CustomerTargets + .Where(ch => ch.FKCusId == cusID && ch.IsActive == true) + .OrderByDescending(ch => ch.CreatedAt) + .ToListAsync(); + + if (!customerTargets.Any()) + throw new NotFoundException("No progress records found"); + + return customerTargets; + } + + public async Task CreateCusHealthRecAsync(CustomerHealth customerHealth) + { + var cushealth = await _central.CustomerHealth + .AddAsync(customerHealth); + + return customerHealth; + } + + public async Task UpdateCusHealthRecAsync(CustomerHealth newCushealth) + { + _central.CustomerHealth.Update(newCushealth); + return newCushealth; + } + + public async Task CreateCustomerTargetAsync(CustomerTarget customerTarget) + { + await _central.CustomerTargets.AddAsync(customerTarget); + return customerTarget; + } + + public async Task GetLatestCustomerTargetAsync(Guid customerId) + { + return await _central.CustomerTargets + .Where(ct => ct.FKCusId == customerId && ct.IsActive) + .OrderByDescending(ct => ct.CreatedAt) + .FirstOrDefaultAsync(); + } + + public async Task> GetCustomerTargetsAsync(Guid customerId) + { + return await _central.CustomerTargets + .Where(ct => ct.FKCusId == customerId && ct.IsActive) + .OrderByDescending(ct => ct.CreatedAt) + .ToListAsync(); + } + + public async Task UpdateLatestCustomerTargetNutritionsAsync(Guid customerId, string predictedNutritionsJson) + { + var target = await _central.CustomerTargets + .Where(ct => ct.FKCusId == customerId && ct.IsActive) + .OrderByDescending(ct => ct.CreatedAt) + .FirstOrDefaultAsync(); + + if (target == null) + throw new NotFoundException("Customer target not found"); + + target.PredictedNutritionsJson = predictedNutritionsJson; + target.IsChanged = true; + + _central.CustomerTargets.Update(target); + return target; + } + + public async Task>> GetCustomerProgressGroupedByGoalAsync(Guid cusID) + { + var progressList = await _central.CustomerTargets + .Where(cp => cp.FKCusId == cusID && cp.IsActive) + .OrderByDescending(cp => cp.CreatedAt) + .ToListAsync(); + + var grouped = progressList + .GroupBy(p => string.IsNullOrWhiteSpace(p.Goal) ? "Unknown" : p.Goal) + .ToDictionary(g => g.Key, g => g.ToList()); + + return grouped; + } + + public async Task UpdateWeeklyProgressAsync(Guid customerId, Guid customerTargetId, decimal weight, DateTime? recordedAt) + { + var target = await _central.CustomerTargets + .FirstOrDefaultAsync(ct => ct.CustomerTargetId == customerTargetId && ct.FKCusId == customerId && ct.IsActive); + + if (target == null) + throw new NotFoundException("Customer target not found"); + + var weeklyProgress = new CusWeightLossProgress + { + FKCusId = customerId, + CustomerTargetId = customerTargetId, + Weight = weight, + RecordedAt = recordedAt ?? DateTime.UtcNow, + IsActive = true + }; + + await _central.CusWeightLossProgress.AddAsync(weeklyProgress); + + return weeklyProgress; + } + + public async Task> GetWeeklyProgressForCustomerAsync(Guid customerId, Guid customerTargetId) + { + var targetExists = await _central.CustomerTargets + .AnyAsync(ct => ct.CustomerTargetId == customerTargetId && ct.FKCusId == customerId && ct.IsActive); + + if (!targetExists) + throw new NotFoundException("Customer target not found"); + + return await _central.CusWeightLossProgress + .Where(wp => wp.FKCusId == customerId && wp.CustomerTargetId == customerTargetId && wp.IsActive) + .OrderByDescending(wp => wp.RecordedAt) + .ToListAsync(); + } + + public async Task VerifyCustomer(Guid userId, string type, string identifire) + { + Customer customer = await _central.Customer + .Where(c => c.FKUserId == userId && c.IsActive) + .FirstOrDefaultAsync(); + + if (customer == null) + return false; + + switch (type) + { + case "PrimaryPH": + customer.IsPrimaryPHVerfied = true; + customer.PrimaryPH = identifire; + break; + case "SecondaryPH": + customer.IsSecondaryPHVerfied = true; + customer.SecondaryPH = identifire; + break; + case "Email": + customer.IsEmailVerfied = true; + customer.Email = identifire; + break; + default: + return false; + } + + _central.Customer.Update(customer); + return true; + } + + public async Task IsverifiedCus(Guid cusId) + { + Customer? verifiedCus = await _central.Customer + .Where(c => c.CusId == cusId && (c.IsEmailVerfied || c.IsPrimaryPHVerfied || c.IsSecondaryPHVerfied) && c.IsActive) + .FirstOrDefaultAsync(); + + if(verifiedCus == null) return false; + + return true; + } + + public async Task> GetCardsByCustomerIdAsync(Guid customerId, CancellationToken ct = default) + { + return await _context.CustomerCards + .AsNoTracking() + .Where(c => c.FK_CustomerId == customerId && c.IsActive) + .ToListAsync(ct); + } + + public async Task GetCardByIdAsync(Guid cardId, CancellationToken ct = default) + { + return await _context.CustomerCards + .AsNoTracking() + .FirstOrDefaultAsync(c => c.JobId == cardId, ct); + } + + public async Task AddCardAsync(CustomerCards card, CancellationToken ct = default) + { + card.JobId = Guid.NewGuid(); + _context.CustomerCards.Add(card); + return card; + } + } +} diff --git a/Repos/CustomerSubscriptionRepository.cs b/Repos/CustomerSubscriptionRepository.cs new file mode 100644 index 0000000..7cbc4ac --- /dev/null +++ b/Repos/CustomerSubscriptionRepository.cs @@ -0,0 +1,240 @@ +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class CustomerSubscriptionRepository : ICustomerSubscriptionRepository + { + private readonly TenantDbContext _context; + private readonly CentralDbContext _central; + + public CustomerSubscriptionRepository(TenantDbContext context, CentralDbContext central) + { + _context = context; + _central = central; + } + + public async Task AddCustomerSubscriptionAsync( + CustomerSubscription subscription, + CustomerSubItem initialSubItem, + SubscriptionMember primaryMember, + IReadOnlyCollection installments, + CancellationToken ct = default) + { + await _context.CustomerSubscriptions.AddAsync(subscription, ct); + await _context.CustomerSubItems.AddAsync(initialSubItem, ct); + await _context.SubscriptionMembers.AddAsync(primaryMember, ct); + await _context.SubscriptionInstallments.AddRangeAsync(installments, ct); + } + + public async Task GetPlanByIdAsync( + Guid customerPlanId, + CancellationToken ct = default) + { + return await _context.CustomerPlans.FindAsync( + new object[] { customerPlanId }, ct); + } + + public Task IsActiveCustomerAsync( + Guid customerId, + CancellationToken ct = default) + { + return _central.Customer.AnyAsync( + customer => customer.CusId == customerId && customer.IsActive, ct); + } + + public Task> GetSubItemsAsync( + Guid subscriptionId, + bool activeOnly, + bool newestFirst, + CancellationToken ct = default) + { + var query = _context.CustomerSubItems + .AsNoTracking() + .Where(item => item.FKCustomerSubscriptionId == subscriptionId); + + if (activeOnly) + query = query.Where(item => item.IsActive); + + query = newestFirst + ? query.OrderByDescending(item => item.CreatedAt) + : query.OrderBy(item => item.CreatedAt); + + return query.ToListAsync(ct); + } + + public async Task AddSubItemAsync( + CustomerSubItem subItem, + CancellationToken ct = default) + { + await _context.CustomerSubItems.AddAsync(subItem, ct); + } + + public Task GetLatestInvoiceAsync( + Guid subscriptionId, + CancellationToken ct = default) + { + // IsActive filter: once an unapproved renewal invoice exists for this subscription, + // "latest" must still resolve to the real, still-collectible prior invoice -- not the + // pending-approval one. Defaults true for every other invoice (no-op elsewhere). + // RolledOver is excluded too: it's superseded by whatever invoice carried its balance + // forward, so it's never "the current invoice" for display/payment purposes. + return _context.CustomerInvoices + .AsNoTracking() + .Where(invoice => + invoice.FKCustomerSubscriptionId == subscriptionId && + invoice.Status != "Cancelled" && + invoice.Status != "RolledOver" && + invoice.IsActive) + .OrderByDescending(invoice => invoice.CustomerInvoiceDate) + .FirstOrDefaultAsync(ct); + } + + public async Task GetApprovedPaymentTotalAsync( + Guid invoiceId, + CancellationToken ct = default) + { + return await _context.PaymentTransactions + .AsNoTracking() + .Where(payment => + payment.FK_CustomerInvoiceId == invoiceId && + payment.IsApproved == true && + payment.IsActive == true) + .Select(payment => (decimal?)payment.PayAmount) + .SumAsync(ct) ?? 0m; + } + + public Task GetCardByIdAsync( + Guid cardId, + CancellationToken ct = default) + { + return _context.CustomerCards + .AsNoTracking() + .FirstOrDefaultAsync(card => card.JobId == cardId, ct); + } + + public async Task GetCustomerSubscriptionById(Guid subscriptionId) + { + return await _context.CustomerSubscriptions + .Include(s => s.TenantCustomerLink) + .Include(s => s.CustomerPlan) + .Include(s => s.SubscriptionMembers) + .ThenInclude(m => m.TenantCustomerLink) + .Include(s => s.SubscriptionInstallments) + .FirstOrDefaultAsync(s => s.SubscriptionId == subscriptionId); + } + + public async Task> GetCustomerSubscriptionsByCustomerId(Guid customerId) + { + return await _context.CustomerSubscriptions + .Include(s => s.CustomerPlan) + .Where(s => s.FKCustomerId == customerId) + .OrderByDescending(s => s.CreatedAt) + .ToListAsync(); + } + + public async Task GetActiveSubscriptionByCustomerId(Guid customerId) + { + return await _context.CustomerSubscriptions + .Include(s => s.TenantCustomerLink) + .Include(s => s.CustomerPlan) + .Where(s => s.FKCustomerId == customerId && s.IsActive) + .OrderByDescending(s => s.CreatedAt) + .FirstOrDefaultAsync(); + } + + // SubscriptionMembers always has a row for the primary too (CustomerSubscriptionFactory + // adds it alongside the CustomerSubscription itself), so this one query covers both + // "primary somewhere" and "secondary/group member somewhere" in a single check. + public Task HasAnyActiveSubscriptionAsync(Guid customerId, CancellationToken ct = default) + { + return _context.SubscriptionMembers.AnyAsync(m => + m.FKCustomerId == customerId && m.IsActive && m.Subscription.IsActive, ct); + } + + // ---------- Group membership ---------- + + public async Task AddSubscriptionMemberAsync(SubscriptionMember member) + { + member.SubscriptionMemberId = Guid.NewGuid(); + member.JoinedAt = DateTime.UtcNow; + member.IsActive = true; + _context.SubscriptionMembers.Add(member); + + return member; + } + + public async Task GetActiveMemberCountAsync(Guid subscriptionId) + { + // Unassigned members still hold their row (so they stay listed and can be put + // back), but they aren't using the plan -- so they don't occupy a MemberLimit slot. + return await _context.SubscriptionMembers + .CountAsync(m => m.FKSubscriptionId == subscriptionId && m.IsActive && m.IsPlanAssigned); + } + + public async Task GetSubscriptionMemberAsync(Guid subscriptionMemberId) + { + return await _context.SubscriptionMembers + .Include(m => m.Subscription) + .ThenInclude(s => s.CustomerPlan) + .FirstOrDefaultAsync(m => m.SubscriptionMemberId == subscriptionMemberId); + } + + public async Task GetActiveSubscriptionMemberByCustomerAsync(Guid subscriptionId, Guid customerId) + { + return await _context.SubscriptionMembers + .FirstOrDefaultAsync(m => m.FKSubscriptionId == subscriptionId && m.FKCustomerId == customerId && m.IsActive); + } + + /// Subscriptions where the customer is an active member (primary OR group secondary). + public async Task> GetSubscriptionsForCustomerAsync(Guid customerId) + { + return await _context.SubscriptionMembers + .AsNoTracking() + .Where(m => m.FKCustomerId == customerId && m.IsActive && m.Subscription.IsActive) + .Include(m => m.Subscription).ThenInclude(s => s.CustomerPlan) + .Include(m => m.Subscription).ThenInclude(s => s.SubscriptionMembers).ThenInclude(sm => sm.TenantCustomerLink) + .Include(m => m.Subscription).ThenInclude(s => s.SubscriptionInstallments) + .Select(m => m.Subscription) + .OrderByDescending(s => s.OnboardDate) + .ToListAsync(); + } + + public async Task> GetInstallmentsAsync(Guid subscriptionId) + { + return await _context.SubscriptionInstallments + .Where(i => i.FKSubscriptionId == subscriptionId && i.IsActive) + .OrderBy(i => i.InstallmentNo) + .ToListAsync(); + } + + public async Task AdvanceRenewalDateAsync(Guid subscriptionId, DateTime newRenewalDate, CancellationToken ct = default) + { + var subscription = await _context.CustomerSubscriptions + .FirstOrDefaultAsync(s => s.SubscriptionId == subscriptionId, ct); + if (subscription == null) + return; + + subscription.RenewalDate = newRenewalDate; + } + + public async Task DeactivatePriorInstallmentsAsync(Guid subscriptionId, CancellationToken ct = default) + { + // Hard delete, not soft-deactivate: TenantDbContext has a unique index on + // (FKSubscriptionId, InstallmentNo), so a soft-deactivated row would still occupy + // its InstallmentNo and collide with the new cycle's schedule restarting at 1 on the + // same subscription. The payment record of truth is PaymentTransactions (linked to + // the invoice, not the installment), so this loses no financial history. + var installments = await _context.SubscriptionInstallments + .Where(i => i.FKSubscriptionId == subscriptionId) + .ToListAsync(ct); + _context.SubscriptionInstallments.RemoveRange(installments); + } + + public async Task AddInstallmentsAsync(IReadOnlyCollection installments, CancellationToken ct = default) + { + await _context.SubscriptionInstallments.AddRangeAsync(installments, ct); + } + } +} diff --git a/Repos/DeviceCommandRepo.cs b/Repos/DeviceCommandRepo.cs new file mode 100644 index 0000000..45c0edb --- /dev/null +++ b/Repos/DeviceCommandRepo.cs @@ -0,0 +1,133 @@ +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class DeviceCommandRepo : IDeviceCommandRepo + { + private readonly TenantDbContext _dbContext; + + public DeviceCommandRepo(TenantDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task AddAsync(DeviceCommand command) + { + await _dbContext.DeviceCommands.AddAsync(command); + return command; + } + + public async Task> GetPendingByClientAsync(Guid clientId) + { + return await _dbContext.DeviceCommands + .Where(c => c.FKClientId == clientId && c.Status == "Pending") + .OrderBy(c => c.CreatedAt) + .ToListAsync(); + } + + public async Task GetByIdAsync(Guid commandId) + { + return await _dbContext.DeviceCommands + .FirstOrDefaultAsync(c => c.Id == commandId); + } + + public Task IsQueuedAsync( + Guid bindingId, + string commandType, + int desiredVersion, + CancellationToken ct = default) + { + return _dbContext.DeviceCommands.AnyAsync(command => + command.FKAccessDeviceBindingId == bindingId && + command.CommandType == commandType && + command.DesiredVersion == desiredVersion && + (command.Status == "Pending" || command.Status == "InProgress"), ct); + } + + public Task> GetPendingStateCommandsAsync( + Guid bindingId, + CancellationToken ct = default) + { + return _dbContext.DeviceCommands + .Where(command => + command.FKAccessDeviceBindingId == bindingId && + command.CommandType == "SetAccessState" && + command.Status == "Pending") + .ToListAsync(ct); + } + + public Task> GetPendingFacePhotoCommandsAsync( + Guid bindingId, + CancellationToken ct = default) + { + return _dbContext.DeviceCommands + .Where(command => + command.FKAccessDeviceBindingId == bindingId && + command.CommandType == "UpsertFacePhoto" && + command.Status == "Pending") + .ToListAsync(ct); + } + + public Task> GetExhaustedLeasesAsync( + Guid clientId, + DateTime now, + CancellationToken ct = default) + { + return _dbContext.DeviceCommands + .Include(command => command.AccessDeviceBinding) + .Where(command => + command.FKClientId == clientId && + command.Status == "InProgress" && + command.LeaseExpiresAt <= now && + command.AttemptCount >= command.MaxAttempts) + .ToListAsync(ct); + } + + public Task> GetLeaseableCommandsAsync( + Guid clientId, + Guid agentId, + DateTime now, + int take, + CancellationToken ct = default) + { + return _dbContext.DeviceCommands + .Include(command => command.AccessDevice) + .Where(command => + command.FKClientId == clientId && + command.AccessDevice != null && + command.AccessDevice.IsActive && + command.AccessDevice.FKAgentId == agentId && + ((command.Status == "Pending" && + (command.NextAttemptAt == null || + command.NextAttemptAt <= now)) || + (command.Status == "InProgress" && + command.LeaseExpiresAt <= now && + command.AttemptCount < command.MaxAttempts))) + .OrderBy(command => command.CreatedAt) + .Take(take) + .ToListAsync(ct); + } + + public Task GetForAgentResultAsync( + Guid commandId, + CancellationToken ct = default) + { + return _dbContext.DeviceCommands + .Include(command => command.AccessDevice) + .Include(command => command.AccessDeviceBinding!) + .ThenInclude(binding => binding.AccessProfile) + .FirstOrDefaultAsync(command => command.Id == commandId, ct); + } + + public Task UpdateStatusAsync(DeviceCommand command, string status, string? resultJson = null, DateTime? processedAt = null) + { + command.Status = status; + command.ResultJson = resultJson; + command.ProcessedAt = processedAt; + _dbContext.DeviceCommands.Update(command); + return Task.CompletedTask; + } + } +} diff --git a/Repos/DocumentRepository.cs b/Repos/DocumentRepository.cs new file mode 100644 index 0000000..12025b8 --- /dev/null +++ b/Repos/DocumentRepository.cs @@ -0,0 +1,66 @@ +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class DocumentRepository : IDocumentRepository + { + private readonly CentralDbContext _context; + + public DocumentRepository(CentralDbContext context) + { + _context = context; + } + + public async Task UploadDocumentAsync(UserDocument userDocument) + { + await _context.UserDocuments.AddAsync(userDocument); + return userDocument; + } + + public async Task GetLastSequnce() + { + var lastDoc = await _context.UserDocuments + .OrderByDescending(d => d.Created_At) + .FirstOrDefaultAsync(); + + if (lastDoc == null) + return "00001"; + + // Extract sequence number from Doc_Name or generate new one + var docs = await _context.UserDocuments.CountAsync(); + return (docs + 1).ToString("D5"); + } + + public async Task> GetDocumentsByUserId(Guid userId) + { + return await _context.UserDocuments + .Include(d => d.DocumentType) + .Where(d => d.UserId == userId && d.IsActive) + .OrderByDescending(d => d.Created_At) + .ToListAsync(); + } + + public async Task GetDocumentById(int documentId) + { + return await _context.UserDocuments + .Include(d => d.DocumentType) + .FirstOrDefaultAsync(d => d.DocumentId == documentId && d.IsActive); + } + + public async Task RemoveDocument(int documentId) + { + var document = await _context.UserDocuments.FindAsync(documentId); + + if (document == null) + { + return false; + } + + document.IsActive = false; + + return true; + } + } +} diff --git a/Repos/FacilityRepository.cs b/Repos/FacilityRepository.cs new file mode 100644 index 0000000..1e55df3 --- /dev/null +++ b/Repos/FacilityRepository.cs @@ -0,0 +1,102 @@ +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class FacilityRepository : IFacilityRepository + { + private readonly TenantDbContext _context; + + public FacilityRepository(TenantDbContext context) + { + _context = context; + } + + public async Task> GetAllFacilities(Guid clientId, bool includeInactive = false) + { + var query = _context.Facilities.Where(f => f.FKClientId == clientId); + if (!includeInactive) + query = query.Where(f => f.IsActive); + + return await query.OrderBy(f => f.Name).ToListAsync(); + } + + public Task GetFacilityById(Guid facilityId) + => _context.Facilities.FirstOrDefaultAsync(f => f.FacilityId == facilityId); + + public async Task CreateFacility(Facility facility) + { + facility.FacilityId = Guid.NewGuid(); + facility.CreatedAt = DateTime.UtcNow; + facility.IsActive = true; + + _context.Facilities.Add(facility); + return facility; + } + + public async Task UpdateFacility(Facility facility) + { + var existing = await _context.Facilities.FindAsync(facility.FacilityId) + ?? throw new KeyNotFoundException("Facility not found"); + + existing.Name = facility.Name; + existing.FacilityType = facility.FacilityType; + existing.Description = facility.Description; + existing.DefaultCapacity = facility.DefaultCapacity; + existing.IsActive = facility.IsActive; + + return existing; + } + + public async Task SetFacilityActive(Guid facilityId, Guid clientId, bool isActive) + { + var facility = await _context.Facilities + .FirstOrDefaultAsync(f => f.FacilityId == facilityId && f.FKClientId == clientId); + if (facility == null) return null; + + facility.IsActive = isActive; + return facility; + } + + public async Task CreateFacilitySession(FacilitySession session) + { + session.FacilitySessionId = Guid.NewGuid(); + session.CreatedAt = DateTime.UtcNow; + session.IsCancelled = false; + + _context.FacilitySessions.Add(session); + return session; + } + + public Task GetFacilitySessionById(Guid facilitySessionId) + => _context.FacilitySessions + .Include(s => s.Facility) + .FirstOrDefaultAsync(s => s.FacilitySessionId == facilitySessionId); + + public async Task> GetSessions(Guid clientId, Guid? facilityId, DateTime fromUtc, DateTime toUtc) + { + var query = _context.FacilitySessions + .Include(s => s.Facility) + .Where(s => s.FKClientId == clientId && s.StartTime >= fromUtc && s.StartTime <= toUtc && !s.IsCancelled); + + if (facilityId.HasValue) + query = query.Where(s => s.FKFacilityId == facilityId.Value); + + return await query.OrderBy(s => s.StartTime).ToListAsync(); + } + + public Task GetActiveBookingCount(Guid facilitySessionId) + => _context.Bookings.CountAsync(b => b.FKFacilitySessionId == facilitySessionId && b.Status != "Cancelled"); + + public async Task CancelFacilitySession(Guid facilitySessionId, Guid clientId) + { + var session = await _context.FacilitySessions + .FirstOrDefaultAsync(s => s.FacilitySessionId == facilitySessionId && s.FKClientId == clientId); + if (session == null) return null; + + session.IsCancelled = true; + return session; + } + } +} diff --git a/Repos/FeedRepository.cs b/Repos/FeedRepository.cs new file mode 100644 index 0000000..c72f2a8 --- /dev/null +++ b/Repos/FeedRepository.cs @@ -0,0 +1,220 @@ +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; +using System.Linq.Expressions; + + +namespace Fitness_core_BE.Repos +{ + public class FeedRepository : IFeedRepository + { + // FeedPost/FeedLike are central-bucket (poster identity spans gyms); Staff and + // CustomerSubscriptions used to scope a feed to one client live in the tenant DB. + private readonly CentralDbContext _central; + private readonly TenantDbContext _dbContext; + + public FeedRepository(CentralDbContext central, TenantDbContext dbContext) + { + _central = central; + _dbContext = dbContext; + } + + public async Task GetFeedPostByIdAsync(Guid creatorId, Guid postId) + { + FeedPost feedPost = await _central.FeedPosts + .Where(p => p.FKUserId == creatorId && p.FeedPostId == postId) + .FirstOrDefaultAsync(); + + if (feedPost == null) + throw new NotFoundException("Post : " + postId + " not found"); + + return feedPost; + } + + public async Task> GetFeedListAsync(int skip, int take) + { + return await _central.FeedPosts + .Include(fp => fp.User) + .Where(fp => fp.IsActive) + .OrderByDescending(fp => fp.CreatedAt) + .Skip(skip) + .Take(take) + .ToListAsync(); + } + + public async Task IsLikedAsync(Guid postId, Guid userId) + { + return await _central.FeedLikes + .AnyAsync(x => x.FKFeedPostId == postId && x.FKUserId == userId); + } + + public async Task AddLikeAsync(Guid postId, Guid userId) + { + _central.FeedLikes.Add(new FeedLike + { + FKFeedPostId = postId, + FKUserId = userId + }); + } + + + public async Task RemoveLikeAsync(Guid postId, Guid userId) + { + var like = await _central.FeedLikes + .FirstOrDefaultAsync(x => x.FKFeedPostId == postId && x.FKUserId == userId); + + if (like != null) + { + _central.FeedLikes.Remove(like); + } + } + + public async Task GetLikeCountAsync(Guid postId) + { + return await _central.FeedLikes + .CountAsync(x => x.FKFeedPostId == postId); + } + + public async Task CreateFeedAsync(FeedPost feedPost, CancellationToken ct = default) + { + await _central.FeedPosts.AddAsync(feedPost, ct); + return true; + } + + public async Task> GetFeedByClientAsync(Guid clientId) + { + // Client (central) and Staff (tenant) can't be joined in one query anymore -- + // resolve the set of poster user ids (owner + staff) first, then filter posts. + var posterUserIds = new List(); + + var clientOwnerUserId = await _central.Client + .Where(client => client.ClientId == clientId) + .Select(client => (Guid?)client.FKUserId) + .FirstOrDefaultAsync(); + if (clientOwnerUserId != null) + posterUserIds.Add(clientOwnerUserId.Value); + + var staffUserIds = await _dbContext.Staff + .Where(staff => staff.FKClientId == clientId) + .Select(staff => staff.FKUserId) + .ToListAsync(); + posterUserIds.AddRange(staffUserIds); + + return await _central.FeedPosts + .Include(fp => fp.User) + .Where(fp => fp.IsActive && posterUserIds.Contains(fp.FKUserId)) + .OrderByDescending(fp => fp.CreatedAt) + .ToListAsync(); + } + + public async Task<(Guid? ClientId, List Posts)> GetFeedForCustomerAsync( + Guid customerUserId, + int skip, + int take, + CancellationToken ct = default) + { + var cusId = await _central.Customer + .Where(customer => customer.FKUserId == customerUserId) + .Select(customer => (Guid?)customer.CusId) + .FirstOrDefaultAsync(ct); + + var clientId = Guid.Empty; + if (cusId != null) + { + clientId = await _dbContext.CustomerSubscriptions + .Where(subscription => + subscription.FKCustomerId == cusId.Value && + subscription.IsActive) + .Select(subscription => subscription.CustomerPlan.FKClientId) + .FirstOrDefaultAsync(ct); + } + + // No active gym: there's no single gym feed to scope to, so fall back to the + // platform-wide feed (every active post) instead of showing nothing. + if (clientId == Guid.Empty) + { + var globalPosts = await _central.FeedPosts + .AsNoTracking() + .Include(post => post.User) + .Where(post => post.IsActive) + .OrderByDescending(post => post.CreatedAt) + .Skip(skip) + .Take(take) + .ToListAsync(ct); + + return (null, globalPosts); + } + + // Poster set for this customer's gym = owner + staff (mirrors GetFeedByClientAsync, + // the client-owner's own view) -- otherwise a staff member's post would show up on + // the gym's own dashboard but never reach that gym's customers. + var posterUserIds = new List(); + + var clientOwnerUserId = await _central.Client + .Where(client => client.ClientId == clientId) + .Select(client => (Guid?)client.FKUserId) + .FirstOrDefaultAsync(ct); + if (clientOwnerUserId != null) + posterUserIds.Add(clientOwnerUserId.Value); + + var staffUserIds = await _dbContext.Staff + .Where(staff => staff.FKClientId == clientId) + .Select(staff => staff.FKUserId) + .ToListAsync(ct); + posterUserIds.AddRange(staffUserIds); + + // Show the gym's own posts alongside this customer's own posts, so a customer + // who creates a post can see it here too, not just their gym's feed. + var posts = await _central.FeedPosts + .AsNoTracking() + .Include(post => post.User) + .Where(post => post.IsActive && (posterUserIds.Contains(post.FKUserId) || post.FKUserId == customerUserId)) + .OrderByDescending(post => post.CreatedAt) + .Skip(skip) + .Take(take) + .ToListAsync(ct); + + return (clientId, posts); + } + + public async Task> GetActiveCustomerIdsByPosterUserIdAsync(Guid posterUserId, CancellationToken ct = default) + { + var clientId = await _central.Client + .Where(client => client.FKUserId == posterUserId) + .Select(client => (Guid?)client.ClientId) + .FirstOrDefaultAsync(ct); + + if (clientId == null || clientId == Guid.Empty) + return new List(); + + return await _dbContext.CustomerSubscriptions + .Where(subscription => subscription.IsActive && subscription.CustomerPlan.FKClientId == clientId) + .Select(subscription => subscription.FKCustomerId) + .Distinct() + .ToListAsync(ct); + } + + public async Task DeletePostPostIdAsync(Guid creatorId, Guid postId) + { + FeedPost post = await _central.FeedPosts + .Where(p => p.FeedPostId == postId && p.FKUserId == creatorId) + .FirstOrDefaultAsync(); + + if (post == null) + throw new NotFoundException("Post : " + postId + "not found"); + + post.IsActive = false; + + try + { + _central.Update(post); + return new { process = "success" }; + } + catch (Exception ex) + { + throw new Exception(ex.Message, ex); + } + } + } +} diff --git a/Repos/GymRoleRepository.cs b/Repos/GymRoleRepository.cs new file mode 100644 index 0000000..845cf01 --- /dev/null +++ b/Repos/GymRoleRepository.cs @@ -0,0 +1,88 @@ +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class GymRoleRepository : IGymRoleRepository + { + private readonly TenantDbContext _context; + + public GymRoleRepository(TenantDbContext context) + { + _context = context; + } + + public async Task> GetRolesByClientId(Guid clientId) + { + return await _context.GymRoles + .Include(r => r.Permissions) + .Where(r => r.FKClientId == clientId) + .OrderBy(r => r.Name) + .ToListAsync(); + } + + public async Task GetRoleById(Guid gymRoleId) + { + return await _context.GymRoles + .Include(r => r.Permissions) + .FirstOrDefaultAsync(r => r.GymRoleId == gymRoleId); + } + + public async Task CreateRole(GymRole role) + { + role.GymRoleId = Guid.NewGuid(); + role.CreatedAt = DateTime.UtcNow; + role.IsActive = true; + _context.GymRoles.Add(role); + return role; + } + + public async Task UpdateRole(GymRole role) + { + return role; + } + + public async Task DeleteRole(GymRole role) + { + _context.GymRoles.Remove(role); + } + + public async Task ReplacePermissions(Guid gymRoleId, IEnumerable navKeys) + { + var existing = _context.GymRolePermissions.Where(p => p.FKGymRoleId == gymRoleId); + _context.GymRolePermissions.RemoveRange(existing); + + foreach (var key in navKeys.Distinct()) + { + _context.GymRolePermissions.Add(new GymRolePermission { FKGymRoleId = gymRoleId, NavItemKey = key }); + } + + } + + public async Task> GetPermissionKeys(Guid gymRoleId) + { + return await _context.GymRolePermissions + .AsNoTracking() + .Where(p => p.FKGymRoleId == gymRoleId) + .Select(p => p.NavItemKey) + .ToListAsync(); + } + + public async Task AssignStaffRole(Guid staffId, Guid clientId, Guid? gymRoleId) + { + var staff = await _context.Staff.FirstOrDefaultAsync(s => s.StaffId == staffId && s.FKClientId == clientId); + if (staff == null) return false; + + if (gymRoleId.HasValue) + { + var roleBelongsToClient = await _context.GymRoles + .AnyAsync(r => r.GymRoleId == gymRoleId.Value && r.FKClientId == clientId); + if (!roleBelongsToClient) return false; + } + + staff.FKGymRoleId = gymRoleId; + return true; + } + } +} diff --git a/Repos/MealRepository.cs b/Repos/MealRepository.cs new file mode 100644 index 0000000..5715a3b --- /dev/null +++ b/Repos/MealRepository.cs @@ -0,0 +1,442 @@ +using Fitness_core_BE.Models; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.DTOs.Meal; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class MealRepository : IMealRepository + { + // Client-authored (non-template) meal plans + all CustomerMeal assignment rows. + private readonly TenantDbContext _dbContext; + // Admin-authored templates (MealCategories, and MealPlans/Meal where IsTemplate=true). + private readonly CentralDbContext _central; + + public MealRepository(TenantDbContext dbContext, CentralDbContext central) + { + _dbContext = dbContext; + _central = central; + } + + // Meal Category operations -- global lookup, admin-owned, always central. + public async Task> GetMealCategoryListAsync(CancellationToken ct = default) + { + return await _central.MealCategories + .AsNoTracking() + .Where(mc => mc.IsActive) + .OrderBy(mc => mc.Name) + .Select(mc => new MealCategoryDto + { + MealCategoryId = mc.MealCategoryId, + Name = mc.Name, + Description = mc.Description, + IsActive = mc.IsActive, + CreateDate = mc.CreateDate + }) + .ToListAsync(ct); + } + + // Categories are central; the plan row is tenant-side with no enforceable cross-DB + // FK, so the id has to be checked here rather than left to the database. + public Task MealCategoryExistsAsync(Guid mealCategoryId, CancellationToken ct = default) + { + return _central.MealCategories + .AsNoTracking() + .AnyAsync(mc => mc.MealCategoryId == mealCategoryId && mc.IsActive, ct); + } + + public async Task CreateMealCategoryAsync(MealCategory mealCategory, CancellationToken ct = default) + { + await _central.MealCategories.AddAsync(mealCategory, ct); + return mealCategory; + } + + // Meal Plan operations + public async Task> GetMealPlanListAsync(string userType, Guid clientId, CancellationToken ct = default) + { + // A customer browsing sees admin-authored templates (central); the coach/owner + // dashboard only manages this gym's own custom plans (tenant) -- these are two + // disjoint sets post-split, never merged in one query. + if (userType == "Customer") + { + return await _central.MealPlans + .AsNoTracking() + .Include(mp => mp.MealCategory) + .Where(mp => mp.IsActive && mp.IsTemplate) + .OrderByDescending(mp => mp.CreateDate) + .Select(mp => new MealPlanDto + { + MealPlanId = mp.MealPlanId, + Name = mp.Name, + Description = mp.Description, + IsTemplate = mp.IsTemplate, + MealCategoryId = mp.FKMealCategoryId, + MealCategoryName = mp.MealCategory != null ? mp.MealCategory.Name : "", + ClientId = mp.FKClientId, + ClientName = null, + IsActive = mp.IsActive, + CreateDate = mp.CreateDate, + AssignedCustomers = new List() + }) + .ToListAsync(ct); + } + + // Categories live centrally, so the tenant query can't join them -- resolve the + // names in a second round-trip and stitch them on below. + var categoryNames = await _central.MealCategories + .AsNoTracking() + .ToDictionaryAsync(mc => mc.MealCategoryId, mc => mc.Name, ct); + + var tenantPlans = await _dbContext.MealPlans + .AsNoTracking() + .Include(mp => mp.CustomerMeals.Where(cm => cm.IsActive)) + .ThenInclude(cm => cm.TenantCustomerLink) + .Where(mp => mp.IsActive && !mp.IsTemplate && mp.FKClientId == clientId) + .OrderByDescending(mp => mp.CreateDate) + .Select(mp => new MealPlanDto + { + MealPlanId = mp.MealPlanId, + Name = mp.Name, + Description = mp.Description, + IsTemplate = mp.IsTemplate, + MealCategoryId = mp.FKMealCategoryId, + MealCategoryName = "", + ClientId = mp.FKClientId, + ClientName = null, + IsActive = mp.IsActive, + CreateDate = mp.CreateDate, + AssignedCustomers = mp.CustomerMeals + .Where(cm => cm.IsActive) + .Select(cm => new CustomerMealDto + { + CustomerMealId = cm.CustomerMealId, + CustomerId = cm.FKCustomerId, + CustomerName = cm.TenantCustomerLink != null ? cm.TenantCustomerLink.DisplayName : null, + MealPlanId = cm.FKMealPlanId, + IsActive = cm.IsActive, + IsFollowing = cm.IsFollowing, + AssignedDate = cm.AssignedDate, + CompletedDate = cm.CompletedDate, + Notes = cm.Notes + }).ToList() + }) + .ToListAsync(ct); + + foreach (var plan in tenantPlans) + { + plan.MealCategoryName = + categoryNames.TryGetValue(plan.MealCategoryId, out var name) ? name : ""; + } + + return tenantPlans; + } + + public async Task GetMealPlanByIdAsync(Guid id, CancellationToken ct = default) + { + // The id could belong to either bucket (an admin template or a client-owned + // plan) -- try central first, fall back to tenant. + var centralPlan = await _central.MealPlans + .AsNoTracking() + .Include(mp => mp.MealCategory) + .Include(mp => mp.MealItems) + .FirstOrDefaultAsync(mp => mp.MealPlanId == id && mp.IsActive, ct); + if (centralPlan != null) + return MapDetail(centralPlan, Array.Empty()); + + var tenantPlan = await _dbContext.MealPlans + .AsNoTracking() + .Include(mp => mp.CustomerMeals.Where(cm => cm.IsActive)) + .ThenInclude(cm => cm.TenantCustomerLink) + .Include(mp => mp.MealItems) + .FirstOrDefaultAsync(mp => mp.MealPlanId == id && mp.IsActive, ct); + if (tenantPlan == null) + return null; + + var detail = MapDetail(tenantPlan, tenantPlan.CustomerMeals.Where(cm => cm.IsActive).ToList()); + // Category is central-only, so it can't be Include()d from the tenant context. + detail.MealCategoryName = await _central.MealCategories + .AsNoTracking() + .Where(mc => mc.MealCategoryId == tenantPlan.FKMealCategoryId) + .Select(mc => mc.Name) + .FirstOrDefaultAsync(ct) ?? ""; + return detail; + } + + private static MealPlanDetailDto MapDetail(MealPlan mealPlan, IReadOnlyCollection assigned) + { + var mealItems = mealPlan.MealItems + .Where(mi => mi.IsActive) + .Select(mi => new MealDto + { + MealId = mi.MealId, + Name = mi.Name, + Description = mi.Description, + Calories = mi.Calories, + Protein = mi.Protein, + Carbohydrates = mi.Carbohydrates, + Fats = mi.Fats, + ServingSize = mi.ServingSize, + MealItems = mi.MealItems, + PreparationNotes = mi.PreparationNotes, + MealPlanId = mi.FKMealPlanId, + IsActive = mi.IsActive, + CreateDate = mi.CreateDate + }) + .ToList(); + + return new MealPlanDetailDto + { + MealPlanId = mealPlan.MealPlanId, + Name = mealPlan.Name, + Description = mealPlan.Description, + IsTemplate = mealPlan.IsTemplate, + MealCategoryId = mealPlan.FKMealCategoryId, + MealCategoryName = mealPlan.MealCategory != null ? mealPlan.MealCategory.Name : "", + ClientId = mealPlan.FKClientId, + ClientName = null, + IsActive = mealPlan.IsActive, + CreateDate = mealPlan.CreateDate, + MealItems = mealItems, + AssignedCustomers = assigned + .Select(cm => new CustomerMealDto + { + CustomerMealId = cm.CustomerMealId, + CustomerId = cm.FKCustomerId, + CustomerName = cm.TenantCustomerLink?.DisplayName, + MealPlanId = cm.FKMealPlanId, + IsActive = cm.IsActive, + IsFollowing = cm.IsFollowing, + AssignedDate = cm.AssignedDate, + CompletedDate = cm.CompletedDate, + Notes = cm.Notes + }).ToList(), + TotalCalories = mealItems.Sum(mi => mi.Calories ?? 0), + TotalProtein = mealItems.Sum(mi => mi.Protein ?? 0), + TotalCarbohydrates = mealItems.Sum(mi => mi.Carbohydrates ?? 0), + TotalFats = mealItems.Sum(mi => mi.Fats ?? 0) + }; + } + + public async Task> GetMealPlansByCustomerIdAsync(Guid customerId, CancellationToken ct = default) + { + // CustomerMeal (tenant) records which plans this customer follows at this gym; + // each followed plan is resolved from whichever bucket it actually lives in. + var follows = await _dbContext.CustomerMeals + .AsNoTracking() + .Where(cm => cm.FKCustomerId == customerId && cm.IsActive) + .OrderByDescending(cm => cm.AssignedDate) + .Select(cm => cm.FKMealPlanId) + .Distinct() + .ToListAsync(ct); + + var result = new List(); + foreach (var mealPlanId in follows) + { + var detail = await GetMealPlanByIdAsync(mealPlanId, ct); + if (detail != null && detail.IsActive) + result.Add(detail); + } + return result; + } + + public async Task GetMealPlanEntityByIdAsync(Guid id, CancellationToken ct = default) + { + return await _dbContext.MealPlans + .FirstOrDefaultAsync(mp => mp.MealPlanId == id && mp.IsActive, ct); + } + + public async Task CreateMealPlanAsync(MealPlan mealPlan, CancellationToken ct = default) + { + await _dbContext.MealPlans.AddAsync(mealPlan, ct); + return mealPlan; + } + + public async Task UpdateMealPlanAsync(MealPlan mealPlan, CancellationToken ct = default) + { + _dbContext.MealPlans.Update(mealPlan); + return mealPlan; + } + + public async Task DeleteMealPlanAsync(Guid id, CancellationToken ct = default) + { + var mealPlan = await _dbContext.MealPlans.FindAsync(new object[] { id }, ct); + if (mealPlan != null) + { + // Soft delete by setting IsActive to false + mealPlan.IsActive = false; + _dbContext.MealPlans.Update(mealPlan); + } + } + + // Meal Item operations + public async Task> GetMealsByMealPlanIdAsync(Guid mealPlanId, CancellationToken ct = default) + { + return await _dbContext.Meal + .AsNoTracking() + .Where(mi => mi.FKMealPlanId == mealPlanId && mi.IsActive) + .OrderBy(mi => mi.CreateDate) + .Select(mi => new MealDto + { + MealId = mi.MealId, + Name = mi.Name, + Description = mi.Description, + Calories = mi.Calories, + Protein = mi.Protein, + Carbohydrates = mi.Carbohydrates, + Fats = mi.Fats, + ServingSize = mi.ServingSize, + MealItems = mi.MealItems, + PreparationNotes = mi.PreparationNotes, + MealPlanId = mi.FKMealPlanId, + IsActive = mi.IsActive, + CreateDate = mi.CreateDate + }) + .ToListAsync(ct); + } + + public async Task GetMealByIdAsync(Guid id, CancellationToken ct = default) + { + return await _dbContext.Meal + .FirstOrDefaultAsync(mi => mi.MealId == id && mi.IsActive, ct); + } + + public async Task CreateMealAsync(Meal mealItem, CancellationToken ct = default) + { + await _dbContext.Meal.AddAsync(mealItem, ct); + return mealItem; + } + + public async Task UpdateMealAsync(Meal mealItem, CancellationToken ct = default) + { + _dbContext.Meal.Update(mealItem); + return mealItem; + } + + public async Task DeleteMealAsync(Guid id, CancellationToken ct = default) + { + var meal = await _dbContext.Meal.FindAsync(new object[] { id }, ct); + if (meal != null) + { + // Soft delete by setting IsActive to false + meal.IsActive = false; + _dbContext.Meal.Update(meal); + } + } + + // Customer Meal Assignment operations + public async Task AssignMealToCustomerAsync(CustomerMeal customerMeal, CancellationToken ct = default) + { + // Mirrors WorkoutRepository.AssignWorkoutToCustomerAsync: a coach assigning a plan + // must become the customer's "Followed" plan, the same as self-following one from + // Suggestions — otherwise an admin-suggested plan the customer already follows keeps + // showing instead of the one the client just assigned. + var currentFollow = await _dbContext.CustomerMeals + .FirstOrDefaultAsync(cm => cm.FKCustomerId == customerMeal.FKCustomerId && cm.IsFollowing, ct); + + if (currentFollow != null && currentFollow.FKMealPlanId != customerMeal.FKMealPlanId) + { + currentFollow.IsFollowing = false; + _dbContext.CustomerMeals.Update(currentFollow); + } + + var existing = await _dbContext.CustomerMeals + .FirstOrDefaultAsync(cm => cm.FKCustomerId == customerMeal.FKCustomerId && cm.FKMealPlanId == customerMeal.FKMealPlanId, ct); + + if (existing != null) + { + existing.IsFollowing = true; + existing.IsActive = true; + existing.AssignedDate = customerMeal.AssignedDate; + existing.Notes = customerMeal.Notes; + _dbContext.CustomerMeals.Update(existing); + return existing; + } + + customerMeal.IsFollowing = true; + await _dbContext.CustomerMeals.AddAsync(customerMeal, ct); + return customerMeal; + } + + public async Task UnassignMealFromCustomerAsync(Guid customerMealId, CancellationToken ct = default) + { + var customerMeal = await _dbContext.CustomerMeals.FindAsync(new object[] { customerMealId }, ct); + + if (customerMeal == null) + { + return false; + } + + // Soft delete by setting IsActive to false + customerMeal.IsActive = false; + _dbContext.CustomerMeals.Update(customerMeal); + + return true; + } + + // A followed plan id may name a central template or a tenant plan, so names are + // resolved from both buckets rather than through a (now un-enforced) navigation. + private async Task> ResolveMealPlanNamesAsync( + IReadOnlyCollection planIds, CancellationToken ct) + { + if (planIds.Count == 0) + return new Dictionary(); + + var names = await _dbContext.MealPlans + .AsNoTracking() + .Where(mp => planIds.Contains(mp.MealPlanId)) + .ToDictionaryAsync(mp => mp.MealPlanId, mp => mp.Name, ct); + + var missing = planIds.Where(id => !names.ContainsKey(id)).ToList(); + if (missing.Count > 0) + { + var centralNames = await _central.MealPlans + .AsNoTracking() + .Where(mp => missing.Contains(mp.MealPlanId)) + .ToDictionaryAsync(mp => mp.MealPlanId, mp => mp.Name, ct); + foreach (var pair in centralNames) + names[pair.Key] = pair.Value; + } + + return names; + } + + private async Task> BuildCustomerMealDtosAsync( + IQueryable query, CancellationToken ct) + { + var rows = await query + .AsNoTracking() + .Include(cm => cm.TenantCustomerLink) + .OrderByDescending(cm => cm.AssignedDate) + .Select(cm => new CustomerMealDto + { + CustomerMealId = cm.CustomerMealId, + CustomerId = cm.FKCustomerId, + CustomerName = cm.TenantCustomerLink != null ? cm.TenantCustomerLink.DisplayName : null, + MealPlanId = cm.FKMealPlanId, + MealPlanName = null, + IsActive = cm.IsActive, + IsFollowing = cm.IsFollowing, + AssignedDate = cm.AssignedDate, + CompletedDate = cm.CompletedDate, + Notes = cm.Notes + }) + .ToListAsync(ct); + + var names = await ResolveMealPlanNamesAsync( + rows.Select(r => r.MealPlanId).Distinct().ToList(), ct); + + foreach (var row in rows) + row.MealPlanName = names.TryGetValue(row.MealPlanId, out var name) ? name : null; + + return rows; + } + + public Task> GetCustomerMealsByPlanIdAsync(Guid mealPlanId, CancellationToken ct = default) + => BuildCustomerMealDtosAsync( + _dbContext.CustomerMeals.Where(cm => cm.FKMealPlanId == mealPlanId && cm.IsActive), ct); + + public Task> GetCustomerMealsByCustomerIdAsync(Guid customerId, CancellationToken ct = default) + => BuildCustomerMealDtosAsync( + _dbContext.CustomerMeals.Where(cm => cm.FKCustomerId == customerId && cm.IsActive), ct); + } +} diff --git a/Repos/NavItemRepository.cs b/Repos/NavItemRepository.cs new file mode 100644 index 0000000..5ce00c2 --- /dev/null +++ b/Repos/NavItemRepository.cs @@ -0,0 +1,25 @@ +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class NavItemRepository : INavItemRepository + { + private readonly CentralDbContext _context; + + public NavItemRepository(CentralDbContext context) + { + _context = context; + } + + public async Task> GetActiveNavItemsOrdered() + { + return await _context.NavItems + .AsNoTracking() + .Where(n => n.IsActive) + .OrderBy(n => n.SortOrder) + .ToListAsync(); + } + } +} diff --git a/Repos/OverdueLockRepository.cs b/Repos/OverdueLockRepository.cs new file mode 100644 index 0000000..7b8ae8a --- /dev/null +++ b/Repos/OverdueLockRepository.cs @@ -0,0 +1,113 @@ +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class OverdueLockRepository : IOverdueLockRepository + { + private readonly TenantDbContext _context; + private readonly CentralDbContext _central; + + public OverdueLockRepository(TenantDbContext context, CentralDbContext central) + { + _context = context; + _central = central; + } + + // A null policy now means "use the platform default" (see Client.DefaultLockAfterDaysOverdue) + // rather than "auto-lock is off" -- only an explicit 0 disables it for a client. + public Task> GetClientsWithOverdueLockPolicyAsync( + CancellationToken ct = default) + { + return _central.Client + .AsNoTracking() + .Where(client => + client.LockAfterDaysOverdue == null || + client.LockAfterDaysOverdue > 0) + .ToListAsync(ct); + } + + public async Task> GetOverdueCustomerIdsAsync( + Guid clientId, + DateTime thresholdDate, + CancellationToken ct = default) + { + // IsActive filter: an unapproved renewal invoice (daily sweep, awaiting staff + // approve/change-plan/reject) must never count toward auto-locking a member -- they + // have no way to pay an invoice that isn't even approved for billing yet. Once it's + // approved (IsActive=true) it's a normal invoice and ages into "overdue" like any + // other; RolledOver invoices are also excluded since their debt now lives on the + // invoice that superseded them, not on this one. + var overdueSubscriptionIds = await _context.CustomerInvoices + .AsNoTracking() + .Where(invoice => + invoice.FKClientId == clientId && + invoice.Status != "Paid" && + invoice.Status != "Cancelled" && + invoice.Status != "RolledOver" && + invoice.IsActive && + invoice.CustomerInvoiceDate <= thresholdDate) + .Select(invoice => invoice.FKCustomerSubscriptionId) + .Distinct() + .ToListAsync(ct); + + if (overdueSubscriptionIds.Count == 0) + return new List(); + + // One invoice covers the whole subscription, so an unpaid one locks every active + // member on it -- not just the primary. On a group/couple plan the secondaries + // never hold an invoice of their own, so selecting the subscription's owner alone + // would leave them with working access while the plan they share is in arrears + // (and would then have them auto-unlocked again by the Except() in OverdueLockJob). + return await _context.SubscriptionMembers + .AsNoTracking() + .Where(member => + member.IsActive && + overdueSubscriptionIds.Contains(member.FKSubscriptionId)) + .Select(member => member.FKCustomerId) + .Distinct() + .ToListAsync(ct); + } + + public Task GetCustomerByIdAsync( + Guid customerId, + CancellationToken ct = default) + { + return _central.Customer + .AsNoTracking() + .FirstOrDefaultAsync(customer => customer.CusId == customerId, ct); + } + + public Task IsCustomerAccessDisabledAsync( + Guid clientId, + Guid userId, + CancellationToken ct = default) + { + return _context.AccessProfiles.AnyAsync(profile => + profile.FKClientId == clientId && + profile.FKUserId == userId && + profile.DesiredAccessState == "Disabled", ct); + } + + public Task> GetCustomersLockedForOverdueAsync( + Guid clientId, + CancellationToken ct = default) + { + return _context.SubscriptionMembers + .AsNoTracking() + .Where(member => + member.IsActive && + member.Subscription.IsActive && + member.Subscription.CustomerPlan.FKClientId == clientId && + _context.AccessProfiles.Any(profile => + profile.FKClientId == clientId && + profile.FKUserId == member.TenantCustomerLink.CentralUserId && + profile.DesiredAccessState == "Disabled" && + profile.LockReason == "Overdue")) + .Select(member => member.FKCustomerId) + .Distinct() + .ToListAsync(ct); + } + } +} diff --git a/Repos/PairingCodeRepo.cs b/Repos/PairingCodeRepo.cs new file mode 100644 index 0000000..792c40e --- /dev/null +++ b/Repos/PairingCodeRepo.cs @@ -0,0 +1,39 @@ +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class PairingCodeRepo : IPairingCodeRepo + { + // Central: created by platform Admin (no TenantId claim) and redeemed by an anonymous + // agent enroll call (no tenant context at all yet). See CentralDbContext.PairingCodes. + private readonly CentralDbContext _dbContext; + + public PairingCodeRepo(CentralDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task AddAsync(PairingCode pairingCode) + { + await _dbContext.PairingCodes.AddAsync(pairingCode); + return pairingCode; + } + + // Active = not used and not expired. + public async Task GetActiveByCodeAsync(string code) + { + var now = DateTime.UtcNow; + return await _dbContext.PairingCodes + .FirstOrDefaultAsync(p => p.Code == code && !p.IsUsed && p.ExpiresAt > now); + } + + public Task MarkUsedAsync(PairingCode pairingCode) + { + pairingCode.IsUsed = true; + _dbContext.PairingCodes.Update(pairingCode); + return Task.CompletedTask; + } + } +} diff --git a/Repos/PaymentRepository.cs b/Repos/PaymentRepository.cs new file mode 100644 index 0000000..a9ca126 --- /dev/null +++ b/Repos/PaymentRepository.cs @@ -0,0 +1,84 @@ +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class PaymentRepository : IPaymentRepository + { + // Row-split entity: member payments (FK_CustomerInvoiceId set) live in the tenant DB, + // platform-billing payments (FK_ClientInvoiceId set) live in the central DB. + private readonly TenantDbContext _context; + private readonly CentralDbContext _central; + + public PaymentRepository(TenantDbContext context, CentralDbContext central) + { + _context = context; + _central = central; + } + + public async Task CreatePaymentAttemptAsync(PaymentTransaction attempt) + { + if (attempt.FK_ClientInvoiceId.HasValue) + await _central.PaymentTransactions.AddAsync(attempt); + else + await _context.PaymentTransactions.AddAsync(attempt); + return attempt; + } + + public async Task GetPaymentByIdAsync(Guid attemptId) + { + var tenant = await _context.PaymentTransactions + .AsNoTracking() + .Include(p => p.CustomerCard) + .Include(p => p.CustomerInvoice) + .FirstOrDefaultAsync(p => p.AttemptId == attemptId && p.IsActive == true); + if (tenant != null) + return tenant; + + return await _central.PaymentTransactions + .AsNoTracking() + .Include(p => p.ClientInvoice) + .FirstOrDefaultAsync(p => p.AttemptId == attemptId && p.IsActive == true); + } + + public async Task> GetPaymentsByCustomerInvoiceIdAsync(Guid customerInvoiceId) + { + return await _context.PaymentTransactions + .AsNoTracking() + .Include(p => p.CustomerCard) + .Where(p => p.FK_CustomerInvoiceId == customerInvoiceId && p.IsActive == true) + .OrderByDescending(p => p.PayDate) + .ToListAsync(); + } + + public async Task> GetPaymentsByClientInvoiceIdAsync(Guid clientInvoiceId) + { + return await _central.PaymentTransactions + .AsNoTracking() + .Where(p => p.FK_ClientInvoiceId == clientInvoiceId && p.IsActive == true) + .OrderByDescending(p => p.PayDate) + .ToListAsync(); + } + + public async Task UpdatePaymentAttemptAsync(PaymentTransaction attempt) + { + if (attempt.FK_ClientInvoiceId.HasValue) + _central.PaymentTransactions.Update(attempt); + else + _context.PaymentTransactions.Update(attempt); + return attempt; + } + + public async Task GetApprovedPaidAmountAsync(Guid customerInvoiceId) + { + return await _context.PaymentTransactions + .AsNoTracking() + .Where(p => p.FK_CustomerInvoiceId == customerInvoiceId + && p.IsApproved == true + && p.IsActive == true) + .Select(p => (decimal?)p.PayAmount) + .SumAsync() ?? 0m; + } + } +} diff --git a/Repos/PaymentsEnablementRepository.cs b/Repos/PaymentsEnablementRepository.cs new file mode 100644 index 0000000..e0e71e0 --- /dev/null +++ b/Repos/PaymentsEnablementRepository.cs @@ -0,0 +1,84 @@ +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Fitness_core_BE.Models.Enums; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class PaymentsEnablementRepository : IPaymentsEnablementRepository + { + private readonly CentralDbContext _context; + + public PaymentsEnablementRepository(CentralDbContext context) + { + _context = context; + } + + public async Task GetWalletByClientIdAsync(Guid clientId, CancellationToken ct = default) + { + return await _context.ClientWallets + .FirstOrDefaultAsync(w => w.FKClientId == clientId, ct); + } + + public async Task> GetLedgerAsync(Guid walletId, CancellationToken ct = default) + { + return await _context.WalletLedgerEntries + .AsNoTracking() + .Where(e => e.FKWalletId == walletId) + .OrderByDescending(e => e.CreatedAt) + .ToListAsync(ct); + } + + public async Task GetKycByClientIdAsync(Guid clientId, CancellationToken ct = default) + { + return await _context.ClientPayoutKycs + .AsNoTracking() + .Include(k => k.Client) + .Where(k => k.FKClientId == clientId) + .OrderByDescending(k => k.SubmittedAt) + .FirstOrDefaultAsync(ct); + } + + public async Task GetKycForUpdateAsync( + Guid clientId, + CancellationToken ct = default) + { + return await _context.ClientPayoutKycs + .Include(k => k.Client) + .Where(k => k.FKClientId == clientId) + .OrderByDescending(k => k.SubmittedAt) + .FirstOrDefaultAsync(ct); + } + + public async Task CreateKycAsync(ClientPayoutKyc kyc, CancellationToken ct = default) + { + await _context.ClientPayoutKycs.AddAsync(kyc, ct); + } + + public async Task<(List Items, int Total)> ListKycAsync(string? status, CancellationToken ct = default) + { + var query = _context.ClientPayoutKycs + .AsNoTracking() + .Include(k => k.Client) + .AsQueryable(); + + if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse(status, true, out var parsedStatus)) + query = query.Where(k => k.KycStatus == parsedStatus); + + var total = await query.CountAsync(ct); + var items = await query.OrderByDescending(k => k.SubmittedAt).ToListAsync(ct); + + return (items, total); + } + + public async Task CreateWalletAsync(ClientWallet wallet, CancellationToken ct = default) + { + await _context.ClientWallets.AddAsync(wallet, ct); + } + + public async Task CreateClientCardAsync(ClientCard card, CancellationToken ct = default) + { + await _context.ClientCard.AddAsync(card, ct); + } + } +} diff --git a/Repos/PlaceRepo.cs b/Repos/PlaceRepo.cs new file mode 100644 index 0000000..10998e9 --- /dev/null +++ b/Repos/PlaceRepo.cs @@ -0,0 +1,143 @@ +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class PlaceRepo : IPlaceRepo + { + private readonly TenantDbContext _context; + + public PlaceRepo(TenantDbContext context) + { + _context = context; + } + + public async Task> GetAllPlaces(Guid clientId, bool includeInactive = false) + { + var query = _context.Places.Where(p => p.FKClientId == clientId); + if (!includeInactive) + query = query.Where(p => p.IsActive); + + return await query.OrderBy(p => p.Name).ToListAsync(); + } + + public Task GetPlaceById(Guid placeId) + => _context.Places.FirstOrDefaultAsync(p => p.PlaceId == placeId); + + public async Task CreatePlace(Place place) + { + place.PlaceId = Guid.NewGuid(); + place.CreatedAt = DateTime.UtcNow; + place.IsActive = true; + + _context.Places.Add(place); + return place; + } + + public async Task UpdatePlace(Place place) + { + var existing = await _context.Places.FindAsync(place.PlaceId) + ?? throw new KeyNotFoundException("Place not found"); + + existing.Name = place.Name; + existing.Description = place.Description; + existing.IsActive = place.IsActive; + + return existing; + } + + public async Task SetPlaceActive(Guid placeId, Guid clientId, bool isActive) + { + var place = await _context.Places + .FirstOrDefaultAsync(p => p.PlaceId == placeId && p.FKClientId == clientId); + if (place == null) return null; + + place.IsActive = isActive; + return place; + } + + public Task GetDeviceByIdForClientAsync(Guid deviceId, Guid clientId) + => _context.AccessDevices + .FirstOrDefaultAsync(d => d.DeviceId == deviceId && d.FKClientId == clientId); + + public async Task> GetPlaceNamesByIdsAsync(List placeIds) + { + return await _context.Places + .Where(p => placeIds.Contains(p.PlaceId)) + .ToDictionaryAsync(p => p.PlaceId, p => p.Name); + } + + public async Task> GetDevicesByPlaceIdsAsync(List placeIds) + { + var devices = await _context.AccessDevices + .Where(d => d.FKPlaceId != null && placeIds.Contains(d.FKPlaceId.Value)) + .ToListAsync(); + + return devices.ToDictionary(d => d.FKPlaceId!.Value, d => d); + } + + public Task IsDeviceAssignedToAnotherPlaceAsync(Guid deviceId, Guid placeId) + => _context.AccessDevices + .AnyAsync(d => d.DeviceId == deviceId && d.FKPlaceId != null && d.FKPlaceId != placeId); + + public async Task AssignDeviceAsync(Guid placeId, Guid deviceId) + { + // A device can only sit in one place at a time -- clear anywhere else it's + // currently assigned before pointing it at this place. + var previouslyAssigned = await _context.AccessDevices + .Where(d => d.FKPlaceId == placeId && d.DeviceId != deviceId) + .ToListAsync(); + foreach (var device in previouslyAssigned) + device.FKPlaceId = null; + + var target = await _context.AccessDevices.FindAsync(deviceId) + ?? throw new KeyNotFoundException("Device not found"); + target.FKPlaceId = placeId; + } + + public async Task UnassignDeviceAsync(Guid placeId) + { + var devices = await _context.AccessDevices + .Where(d => d.FKPlaceId == placeId) + .ToListAsync(); + foreach (var device in devices) + device.FKPlaceId = null; + } + + public async Task SetPlanPlacesAsync(Guid customerPlanId, List placeIds) + { + var existing = await _context.CustomerPlanPlaces + .Where(pp => pp.FKCustomerPlanId == customerPlanId) + .ToListAsync(); + _context.CustomerPlanPlaces.RemoveRange(existing); + + foreach (var placeId in placeIds.Distinct()) + { + _context.CustomerPlanPlaces.Add(new CustomerPlanPlace + { + CustomerPlanPlaceId = Guid.NewGuid(), + FKCustomerPlanId = customerPlanId, + FKPlaceId = placeId + }); + } + } + + public Task> GetPlaceIdsForPlanAsync(Guid customerPlanId) + => _context.CustomerPlanPlaces + .Where(pp => pp.FKCustomerPlanId == customerPlanId) + .Select(pp => pp.FKPlaceId) + .ToListAsync(); + + public async Task>> GetPlaceIdsByPlanIdsAsync(List customerPlanIds) + { + var rows = await _context.CustomerPlanPlaces + .Where(pp => customerPlanIds.Contains(pp.FKCustomerPlanId)) + .ToListAsync(); + + return rows + .GroupBy(pp => pp.FKCustomerPlanId) + .ToDictionary(g => g.Key, g => g.Select(pp => pp.FKPlaceId).ToList()); + } + } +} diff --git a/Repos/PlatformBillingRepository.cs b/Repos/PlatformBillingRepository.cs new file mode 100644 index 0000000..28e7627 --- /dev/null +++ b/Repos/PlatformBillingRepository.cs @@ -0,0 +1,264 @@ +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class PlatformBillingRepository : IPlatformBillingRepository + { + // Platform billing is central-bucket (HexaFitness's own revenue relationship with + // each gym); AccessDevices (used only to check device-billing eligibility) is tenant. + private readonly CentralDbContext _context; + private readonly TenantDbContext _tenant; + + public PlatformBillingRepository(CentralDbContext context, TenantDbContext tenant) + { + _context = context; + _tenant = tenant; + } + + public Task> GetPricingPlansAsync(CancellationToken ct = default) + { + return _context.ClientPackages + .AsNoTracking() + .OrderByDescending(package => package.IsDefault) + .ThenBy(package => package.PackageName) + .ToListAsync(ct); + } + + public async Task GetPackageByIdAsync( + Guid packageId, + CancellationToken ct = default) + { + return await _context.ClientPackages.FindAsync(new object[] { packageId }, ct); + } + + public async Task AddPackageAsync( + ClientPackage package, + CancellationToken ct = default) + { + await _context.ClientPackages.AddAsync(package, ct); + } + + public Task> GetOtherDefaultPackagesAsync( + Guid exceptPackageId, + CancellationToken ct = default) + { + return _context.ClientPackages + .Where(package => + package.IsDefault && + package.ClientPackageId != exceptPackageId) + .ToListAsync(ct); + } + + public Task GetDefaultPackageAsync(CancellationToken ct = default) + { + return _context.ClientPackages + .FirstOrDefaultAsync(package => package.IsDefault, ct); + } + + public async Task GetActivePackageForClientAsync( + Guid clientId, + CancellationToken ct = default) + { + return await _context.ClientSubscriptions + .AsNoTracking() + .Where(subscription => + subscription.FKClientId == clientId && + subscription.IsActive) + .OrderByDescending(subscription => subscription.OnboardDate) + .Select(subscription => subscription.ClientPackage) + .FirstOrDefaultAsync(ct); + } + + public Task GetClientByIdAsync( + Guid clientId, + CancellationToken ct = default) + { + return _context.Client + .FirstOrDefaultAsync(client => client.ClientId == clientId, ct); + } + + public Task> GetActiveClientSubscriptionsAsync( + Guid clientId, + CancellationToken ct = default) + { + return _context.ClientSubscriptions + .Where(subscription => + subscription.FKClientId == clientId && + subscription.IsActive) + .ToListAsync(ct); + } + + public Task GetActiveClientSubscriptionAsync( + Guid clientId, + CancellationToken ct = default) + { + return _context.ClientSubscriptions + .Where(subscription => + subscription.FKClientId == clientId && + subscription.IsActive) + .OrderByDescending(subscription => subscription.OnboardDate) + .FirstOrDefaultAsync(ct); + } + + public async Task AddClientSubscriptionAsync( + ClientSubscription subscription, + CancellationToken ct = default) + { + await _context.ClientSubscriptions.AddAsync(subscription, ct); + } + + public Task> GetActiveDeviceBillingsAsync( + Guid clientId, + CancellationToken ct = default) + { + return _context.ClientDeviceBillings + .Where(billing => + billing.FKClientId == clientId && + billing.IsActive) + .ToListAsync(ct); + } + + public Task GetLatestActiveDeviceBillingAsync( + Guid clientId, + CancellationToken ct = default) + { + return _context.ClientDeviceBillings + .Where(billing => + billing.FKClientId == clientId && + billing.IsActive) + .OrderByDescending(billing => billing.AssignedAt) + .FirstOrDefaultAsync(ct); + } + + public async Task AddDeviceBillingAsync( + ClientDeviceBilling billing, + CancellationToken ct = default) + { + await _context.ClientDeviceBillings.AddAsync(billing, ct); + } + + public Task HasActiveDeviceAsync( + Guid clientId, + CancellationToken ct = default) + { + return _tenant.AccessDevices.AnyAsync(device => + device.FKClientId == clientId && + device.IsActive, ct); + } + + public Task> GetMonthlyBillingCandidatesAsync( + DateTime startOfMonth, + CancellationToken ct = default) + { + return _context.Client + .AsNoTracking() + .Where(client => + _context.ClientCard.Any(card => + card.FK_ClientId == client.ClientId) && + !_context.ClientInvoices.Any(invoice => + invoice.ClientSubscription.FKClientId == client.ClientId && + invoice.ClientInvoiceDate >= startOfMonth)) + .Select(client => client.ClientId) + .ToListAsync(ct); + } + + public Task> GetInvoicesByStatusAsync( + string status, + bool newestFirst, + CancellationToken ct = default) + { + var query = _context.ClientInvoices + .AsNoTracking() + .Include(invoice => invoice.ClientSubscription) + .ThenInclude(subscription => subscription.Client) + .ThenInclude(client => client.ClientBusinesses) + .Where(invoice => + status == "Outstanding" + ? invoice.Status == "Pending" || invoice.Status == "Partial" + : invoice.Status == status); + + query = newestFirst + ? query.OrderByDescending(invoice => invoice.ClientInvoiceDate) + : query.OrderBy(invoice => invoice.ClientInvoiceDate); + + return query.ToListAsync(ct); + } + + public Task GetInvoiceWithClientAsync( + Guid invoiceId, + bool includeBusinesses, + CancellationToken ct = default) + { + var query = _context.ClientInvoices + .Include(invoice => invoice.ClientSubscription) + .ThenInclude(subscription => subscription.Client) + .AsQueryable(); + + if (includeBusinesses) + { + query = query.Include(invoice => + invoice.ClientSubscription.Client.ClientBusinesses); + } + + return query.FirstOrDefaultAsync( + invoice => invoice.ClientInvoiceId == invoiceId, ct); + } + + public Task GetClientCardAsync( + Guid clientId, + CancellationToken ct = default) + { + // The card billing charges: the active card marked primary. CardToken != null + // excludes legacy rows created by ActivationService.ChargeAndActivateAsync before + // real card onboarding existed — those were never confirmed by Payment-Service and + // can't actually be charged, so they must never outrank (or stand in for) a real card. + return _context.ClientCard + .Where(card => card.FK_ClientId == clientId && card.IsActive && card.CardToken != null) + .OrderByDescending(card => card.IsPrimary) + .FirstOrDefaultAsync(ct); + } + + public Task> GetClientCardsAsync( + Guid clientId, + CancellationToken ct = default) + { + return _context.ClientCard + .Where(card => card.FK_ClientId == clientId && card.IsActive && card.CardToken != null) + .OrderByDescending(card => card.IsPrimary) + .ToListAsync(ct); + } + + public Task GetClientCardByTokenAsync( + Guid clientId, + string cardToken, + CancellationToken ct = default) + { + return _context.ClientCard + .FirstOrDefaultAsync(card => card.FK_ClientId == clientId && card.CardToken == cardToken, ct); + } + + public async Task AddInvoiceAsync( + ClientInvoice invoice, + IReadOnlyCollection items, + CancellationToken ct = default) + { + await _context.ClientInvoices.AddAsync(invoice, ct); + await _context.ClientInvoiceItems.AddRangeAsync(items, ct); + } + + public Task GetDevicePricingConfigAsync( + CancellationToken ct = default) + { + return _context.DevicePricingConfigs.FirstOrDefaultAsync(ct); + } + + public async Task AddDevicePricingConfigAsync( + DevicePricingConfig config, + CancellationToken ct = default) + { + await _context.DevicePricingConfigs.AddAsync(config, ct); + } + } +} diff --git a/Repos/ProfitLossRepository.cs b/Repos/ProfitLossRepository.cs new file mode 100644 index 0000000..0bdc0ff --- /dev/null +++ b/Repos/ProfitLossRepository.cs @@ -0,0 +1,124 @@ +using Fitness_core_BE.DTOs.Ledger; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class ProfitLossRepository : IProfitLossRepository + { + // Every method here is already scoped to a single caller-owned clientId (resolved + // from the ambient tenant), so no cross-tenant fan-out is needed. PaymentTransactions + // (member-payment rows), StaffPayments and LedgerEntries are tenant-bucket; + // ClientInvoices (platform billing) is central. + private readonly TenantDbContext _context; + private readonly CentralDbContext _central; + + public ProfitLossRepository(TenantDbContext context, CentralDbContext central) + { + _context = context; + _central = central; + } + + public async Task> GetDerivedEntriesAsync( + Guid clientId, + DateTime fromUtc, + DateTime toUtc, + CancellationToken ct = default) + { + var memberPayments = await _context.PaymentTransactions + .AsNoTracking() + .Where(payment => + payment.FK_ClientId == clientId && + payment.FK_CustomerInvoiceId != null && + payment.IsApproved == true && + payment.IsActive == true && + payment.PayDate != null && + payment.PayDate >= fromUtc && + payment.PayDate < toUtc) + .Select(payment => new LedgerEntryDto + { + EntryType = "Income", + Category = "Member Payments", + Description = payment.PayNote, + Amount = payment.PayAmount, + EntryDate = payment.PayDate!.Value, + IsManual = false + }) + .ToListAsync(ct); + + var platformInvoices = await _central.ClientInvoices + .AsNoTracking() + .Where(invoice => + invoice.ClientSubscription.FKClientId == clientId && + invoice.PaidAmount > 0 && + invoice.ClientInvoiceDate >= fromUtc && + invoice.ClientInvoiceDate < toUtc) + .Select(invoice => new LedgerEntryDto + { + EntryType = "Expense", + Category = "Platform Fees", + Description = "HexaFitness platform invoice", + Amount = invoice.PaidAmount, + EntryDate = invoice.ClientInvoiceDate, + IsManual = false + }) + .ToListAsync(ct); + + var staffPayments = await _context.StaffPayments + .AsNoTracking() + .Where(payment => + payment.Staff.FKClientId == clientId && + payment.PaidAt >= fromUtc && + payment.PaidAt < toUtc) + .Select(payment => new LedgerEntryDto + { + EntryType = "Expense", + Category = "Staff Payroll", + Description = payment.Note, + Amount = payment.Amount, + EntryDate = payment.PaidAt, + IsManual = false + }) + .ToListAsync(ct); + + memberPayments.AddRange(platformInvoices); + memberPayments.AddRange(staffPayments); + return memberPayments; + } + + public Task> GetManualEntriesAsync( + Guid clientId, + DateTime fromUtc, + DateTime toUtc, + CancellationToken ct = default) + { + return _context.LedgerEntries + .AsNoTracking() + .Where(entry => + entry.FKClientId == clientId && + entry.EntryDate >= fromUtc && + entry.EntryDate < toUtc) + .ToListAsync(ct); + } + + public Task GetManualEntryByIdAsync( + Guid id, + CancellationToken ct = default) + { + return _context.LedgerEntries.FirstOrDefaultAsync(entry => entry.Id == id, ct); + } + + public async Task AddManualEntryAsync( + LedgerEntry entry, + CancellationToken ct = default) + { + await _context.LedgerEntries.AddAsync(entry, ct); + } + + public void RemoveManualEntry(LedgerEntry entry) + { + _context.LedgerEntries.Remove(entry); + } + } +} diff --git a/Repos/StaffRepository.cs b/Repos/StaffRepository.cs new file mode 100644 index 0000000..013079c --- /dev/null +++ b/Repos/StaffRepository.cs @@ -0,0 +1,205 @@ +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class StaffRepository : IStaffRepository + { + private readonly TenantDbContext _context; + private readonly CentralDbContext _central; + + public StaffRepository(TenantDbContext context, CentralDbContext central) + { + _context = context; + _central = central; + } + + // Staff.User/Staff.Client are central-bucket entities (not a real relationship once + // physically split) -- these load the central User rows separately and attach them + // in memory onto the already-fetched Staff objects, so callers reading staff.User.* + // still see the right data without needing to change. + private async Task AttachUsersAsync(IEnumerable staffList) + { + var userIds = staffList.Select(s => s.FKUserId).Distinct().ToList(); + if (userIds.Count == 0) return; + + var users = await _central.Users + .AsNoTracking() + .Where(u => userIds.Contains(u.UserId)) + .ToDictionaryAsync(u => u.UserId); + + foreach (var staff in staffList) + { + if (users.TryGetValue(staff.FKUserId, out var user)) + staff.User = user; + } + } + + public async Task> GetAllStaff(bool includeInactive = false) + { + var query = _context.Staff + .Include(s => s.GymRole) + .AsQueryable(); + + if (!includeInactive) + { + query = query.Where(s => s.IsActive); + } + + var staffList = await query.ToListAsync(); + await AttachUsersAsync(staffList); + return staffList.OrderBy(s => s.User?.UserName).ToList(); + } + + public async Task GetStaffById(Guid staffId) + { + var staff = await _context.Staff + .Include(s => s.GymRole) + .FirstOrDefaultAsync(s => s.StaffId == staffId); + if (staff != null) + await AttachUsersAsync(new[] { staff }); + return staff; + } + + public async Task> GetStaffByClientId(Guid clientId, bool includeInactive = false) + { + var query = _context.Staff + .Include(s => s.GymRole) + .Where(s => s.FKClientId == clientId); + + if (!includeInactive) + { + query = query.Where(s => s.IsActive); + } + + var staffList = await query.ToListAsync(); + await AttachUsersAsync(staffList); + return staffList.OrderBy(s => s.User?.UserName).ToList(); + } + + public async Task CreateStaff(Staff staff, User user) + { + // Create User first (central) + user.UserId = Guid.NewGuid(); + user.CreateDate = DateTime.UtcNow; + user.IsActive = true; + _central.Users.Add(user); + + // Create Staff (tenant) + staff.StaffId = Guid.NewGuid(); + staff.FKUserId = user.UserId; + staff.CreatedAt = DateTime.UtcNow; + staff.HireDate = staff.HireDate == default ? DateTime.UtcNow : staff.HireDate; + staff.IsActive = true; + + // Verify Client exists (central) + var clientExists = await _central.Client.AnyAsync(c => c.ClientId == staff.FKClientId); + if (!clientExists) + { + throw new Exception("Client not found"); + } + + _context.Staff.Add(staff); + + return staff; + } + + public async Task UpdateStaff(Staff staff) + { + var existingStaff = await _context.Staff + .FirstOrDefaultAsync(s => s.StaffId == staff.StaffId); + + if (existingStaff == null) + { + throw new Exception("Staff not found"); + } + + // Update User fields if provided -- User lives in the central DB. + if (staff.User != null && (!string.IsNullOrEmpty(staff.User.UserName) + || !string.IsNullOrEmpty(staff.User.Email) + || !string.IsNullOrEmpty(staff.User.PhoneNumber))) + { + var centralUser = await _central.Users.FirstOrDefaultAsync(u => u.UserId == existingStaff.FKUserId); + if (centralUser != null) + { + if (!string.IsNullOrEmpty(staff.User.UserName)) + centralUser.UserName = staff.User.UserName; + if (!string.IsNullOrEmpty(staff.User.Email)) + centralUser.Email = staff.User.Email; + if (!string.IsNullOrEmpty(staff.User.PhoneNumber)) + centralUser.PhoneNumber = staff.User.PhoneNumber; + existingStaff.User = centralUser; + } + } + + // Update Staff fields if provided + if (!string.IsNullOrEmpty(staff.StaffPosition)) + { + existingStaff.StaffPosition = staff.StaffPosition; + } + + if (!string.IsNullOrEmpty(staff.Department)) + { + existingStaff.Department = staff.Department; + } + + // Update IsActive explicitly + existingStaff.IsActive = staff.IsActive; + + return existingStaff; + } + + public async Task DeleteStaff(Guid staffId, bool softDelete = true) + { + var staff = await _context.Staff.FindAsync(staffId); + + if (staff == null) + { + return false; + } + + if (softDelete) + { + // Soft delete: just mark as inactive + staff.IsActive = false; + } + else + { + // Hard delete: remove from database + _context.Staff.Remove(staff); + } + + return true; + } + + public async Task GetActivePayRateAsync(Guid staffId) + { + return await _context.StaffPayRates + .Where(r => r.FKStaffId == staffId && r.IsActive) + .OrderByDescending(r => r.EffectiveFrom) + .FirstOrDefaultAsync(); + } + + public async Task AddPayRateAsync(StaffPayRate rate) + { + await _context.StaffPayRates.AddAsync(rate); + return rate; + } + + public async Task> GetPaymentsAsync(Guid staffId) + { + return await _context.StaffPayments + .AsNoTracking() + .Where(p => p.FKStaffId == staffId) + .OrderByDescending(p => p.PaidAt) + .ToListAsync(); + } + + public async Task AddPaymentAsync(StaffPayment payment) + { + _context.StaffPayments.Add(payment); + return payment; + } + } +} diff --git a/Repos/TermsRepository.cs b/Repos/TermsRepository.cs new file mode 100644 index 0000000..27030d7 --- /dev/null +++ b/Repos/TermsRepository.cs @@ -0,0 +1,57 @@ +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class TermsRepository : ITermsRepository + { + private readonly TenantDbContext _context; + + public TermsRepository(TenantDbContext context) + { + _context = context; + } + + public async Task GetActiveByClientIdAsync(Guid clientId) + { + return await _context.ClientTerms + .AsNoTracking() + .Where(t => t.FKClientId == clientId && t.IsActive) + .OrderByDescending(t => t.Version) + .FirstOrDefaultAsync(); + } + + public Task> GetActiveForUpdateAsync(Guid clientId) + { + return _context.ClientTerms + .Where(t => t.FKClientId == clientId && t.IsActive) + .ToListAsync(); + } + + public async Task GetLatestVersionAsync(Guid clientId) + { + return (await _context.ClientTerms + .Where(t => t.FKClientId == clientId) + .Select(t => (int?)t.Version) + .MaxAsync()) ?? 0; + } + + public async Task AddTermsAsync(ClientTerms terms) + { + await _context.ClientTerms.AddAsync(terms); + } + + public async Task AddAcceptanceAsync(CustomerTermsAcceptance acceptance) + { + await _context.CustomerTermsAcceptances.AddAsync(acceptance); + } + + public async Task GetAcceptanceAsync(Guid cusId, Guid clientId, int version) + { + return await _context.CustomerTermsAcceptances + .AsNoTracking() + .FirstOrDefaultAsync(a => a.FKCusId == cusId && a.FKClientId == clientId && a.Version == version); + } + } +} diff --git a/Repos/TicketRepository.cs b/Repos/TicketRepository.cs new file mode 100644 index 0000000..0ecbabd --- /dev/null +++ b/Repos/TicketRepository.cs @@ -0,0 +1,202 @@ +using Fitness_core_BE.Models; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.DTOs.Ticket; +using Microsoft.EntityFrameworkCore; +using Fitness_core_BE.Exceptions; + +namespace Fitness_core_BE.Repos +{ + public class TicketRepository : ITicketRepository + { + private readonly CentralDbContext _dbContext; + + public TicketRepository(CentralDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task GetTicketCatId (string type) + { + Guid TicketCatId = await _dbContext.TicketCategories + .Where(tc => tc.TicketCategoryName == type && tc.IsActive) + .Select(tc => tc.TicketCategoryId) + .FirstOrDefaultAsync(); + + return TicketCatId; + } + + public async Task> GetTicketListAsync(CancellationToken ct = default) + { + return await _dbContext.Tickets + .AsNoTracking() + .Include(t => t.User) + .Include(t => t.Admin) + .Include(t => t.TicketCategory) + .Where(t => t.IsActive) + .OrderByDescending(t => t.CreateDate) + .Select(t => new TicketDto + { + TicketId = t.TicketId, + MainReason = t.MainReason, + CreateDate = t.CreateDate, + UserId = t.FKUserId, + UserName = t.User != null ? t.User.UserName : "", + UserEmail = t.User != null ? t.User.Email : "", + AdminId = t.FKAssignerId, + AdminName = t.Admin != null ? t.Admin.AdminName : null, + TicketCategoryId = t.FKTicketCategoryId, + TicketCategoryName = t.TicketCategory != null ? t.TicketCategory.TicketCategoryName : null, + PriorityLevel = t.TicketCategory != null ? t.TicketCategory.PriorityLevel : (Fitness_core_BE.Models.Enums.PriorityLevel?)null, + UserRating = t.UserRating, + TicketStatus = t.TicketStatus + }) + .ToListAsync(ct); + } + + public async Task> GetTicketByUserIdAsync(Guid userId, CancellationToken ct = default) + { + return await _dbContext.Tickets + .AsNoTracking() + .Include(t => t.User) + .Include(t => t.Admin) + .Include(t => t.TicketCategory) + .Where(t => t.FKUserId == userId && t.IsActive) + .OrderByDescending(t => t.CreateDate) + .Select(t => new TicketDto + { + TicketId = t.TicketId, + MainReason = t.MainReason, + CreateDate = t.CreateDate, + UserId = t.FKUserId, + UserName = t.User != null ? t.User.UserName : "", + UserEmail = t.User != null ? t.User.Email : "", + AdminId = t.FKAssignerId, + AdminName = t.Admin != null ? t.Admin.AdminName : null, + TicketCategoryId = t.FKTicketCategoryId, + TicketCategoryName = t.TicketCategory != null ? t.TicketCategory.TicketCategoryName : null, + PriorityLevel = t.TicketCategory != null ? t.TicketCategory.PriorityLevel : (Fitness_core_BE.Models.Enums.PriorityLevel?)null, + UserRating = t.UserRating, + TicketStatus = t.TicketStatus + }) + .ToListAsync(ct); + } + + public async Task GetByTicketIdAsync(Guid id, CancellationToken ct = default) + { + var ticket = await _dbContext.Tickets + .AsNoTracking() + .Include(t => t.User) + .Include(t => t.Admin) + .Include(t => t.TicketCategory) + .Include(t => t.Messages) + .FirstOrDefaultAsync(t => t.TicketId == id && t.IsActive, ct); + + if (ticket == null) + return null; + + return new TicketDetailDto + { + TicketId = ticket.TicketId, + MainReason = ticket.MainReason, + CreateDate = ticket.CreateDate, + UserId = ticket.FKUserId, + UserName = ticket.User?.UserName ?? "", + UserEmail = ticket.User?.Email ?? "", + UserPhoneNumber = ticket.User?.PhoneNumber ?? "", + AdminId = ticket.FKAssignerId, + AdminName = ticket.Admin?.AdminName, + AdminPhoneNumber = ticket.Admin?.PhoneNumber, + TicketCategoryId = ticket.FKTicketCategoryId, + TicketCategoryName = ticket.TicketCategory?.TicketCategoryName, + PriorityLevel = ticket.TicketCategory?.PriorityLevel, + TicketStatus = ticket.TicketStatus, + UserRating = ticket.UserRating, + UserFeedback = ticket.UserFeedback, + FeedbackAt = ticket.FeedbackAt, + Messages = ticket.Messages?.Select(m => new TicketMessageDto + { + MessageId = m.MessageId, + IsSentByAdmin = m.IsSentByAdmin, + Message = m.Message, + FKDocumentId = m.FKDocumentId, + AttachmentURL = m.AttachmentURL, + CreateDate = m.CreateDate + }).OrderBy(m => m.CreateDate).ToList() ?? new List() + }; + } + + public Task CreateTicketAsync(Ticket ticket, CancellationToken ct = default) + { + _dbContext.Tickets.Add(ticket); + return Task.FromResult(ticket); + } + + public async Task UpdateTicketAsync(TicketDetailDto ticketDto, CancellationToken ct = default) + { + Ticket ticket = await _dbContext.Tickets.FirstOrDefaultAsync(t => t.TicketId == ticketDto.TicketId, ct) ?? throw new NotFoundException($"Ticket with ID {ticketDto.TicketId} not found"); + + ticket.MainReason = ticketDto.MainReason; + ticket.FKTicketCategoryId = ticketDto.TicketCategoryId; + + _dbContext.Tickets.Update(ticket); + return ticket; + } + + public async Task DeleteAsync(Guid id, CancellationToken ct = default) + { + var entity = await _dbContext.Tickets.FirstOrDefaultAsync(t => t.TicketId == id, ct); + if (entity == null) + return false; + + entity.IsActive = false; + return true; + } + + // Ticket category operations + public async Task> GetTicketCategoryListAsync(CancellationToken ct = default) + { + return await _dbContext.TicketCategories + .AsNoTracking() + .Where(tc => tc.IsActive) + .OrderBy(tc => tc.TicketCategoryName) + .ToListAsync(ct); + } + + public async Task GetTicketCategoryByIdAsync(Guid id, CancellationToken ct = default) + { + return await _dbContext.TicketCategories + .AsNoTracking() + .FirstOrDefaultAsync(tc => tc.TicketCategoryId == id && tc.IsActive, ct); + } + + // Ticket message operations + public async Task SendMessageAsync(TicketMessage ticketMessage, CancellationToken ct = default) + { + _dbContext.TicketMessages.Add(ticketMessage); + return ticketMessage; + } + + public async Task SubmitTicketFeedbackAsync(Guid ticketId, int rating, string? feedback, CancellationToken ct = default) + { + var entity = await _dbContext.Tickets.FirstOrDefaultAsync(t => t.TicketId == ticketId, ct) ?? throw new NotFoundException($"Ticket with ID {ticketId} not found"); + + entity.UserRating = rating; + if (feedback != null) + entity.UserFeedback = feedback; + entity.FeedbackAt = DateTime.Now; + + _dbContext.Tickets.Update(entity); + + return entity; + } + + public async Task UpdateTicketStatusAsync(Guid ticketId, Fitness_core_BE.Models.Enums.TicketStatusEnum statusCode) + { + var ticket = await _dbContext.Tickets.FirstOrDefaultAsync(t => t.TicketId == ticketId); + if (ticket == null) + throw new NotFoundException($"Ticket with ID {ticketId} not found"); + + ticket.TicketStatus = statusCode; + } + } +} diff --git a/Repos/TimeBankCardRepository.cs b/Repos/TimeBankCardRepository.cs new file mode 100644 index 0000000..b5d706a --- /dev/null +++ b/Repos/TimeBankCardRepository.cs @@ -0,0 +1,50 @@ +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class TimeBankCardRepository : ITimeBankCardRepository + { + private readonly TenantDbContext _context; + + public TimeBankCardRepository(TenantDbContext context) + { + _context = context; + } + + public async Task CreateTimeBankCard(TimeBankCard card) + { + card.TimeBankCardId = Guid.NewGuid(); + card.CreatedAt = DateTime.UtcNow; + card.IsActive = true; + + _context.TimeBankCards.Add(card); + return card; + } + + public Task GetById(Guid timeBankCardId) + => _context.TimeBankCards.FirstOrDefaultAsync(c => c.TimeBankCardId == timeBankCardId); + + public Task GetByRfidTagId(string rfidTagId) + => _context.TimeBankCards + .Include(c => c.ActiveSessionFacility) + .FirstOrDefaultAsync(c => c.RfidTagId == rfidTagId && c.IsActive); + + public Task> GetByCustomerId(Guid customerId) + => _context.TimeBankCards + .Include(c => c.ActiveSessionFacility) + .Where(c => c.FKCustomerId == customerId) + .OrderByDescending(c => c.PurchasedAt) + .ToListAsync(); + + public async Task AddUsageLog(TimeBankCardUsageLog log) + { + log.TimeBankCardUsageLogId = Guid.NewGuid(); + log.CreatedAt = DateTime.UtcNow; + + _context.TimeBankCardUsageLogs.Add(log); + return log; + } + } +} diff --git a/Repos/WorkoutRepository.cs b/Repos/WorkoutRepository.cs new file mode 100644 index 0000000..445cf11 --- /dev/null +++ b/Repos/WorkoutRepository.cs @@ -0,0 +1,459 @@ +using Fitness_core_BE.Models; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.DTOs.Workout; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class WorkoutRepository : IWorkoutRepository + { + // Client-authored (non-template) workout plans + all CustomerWorkout assignment rows. + private readonly TenantDbContext _dbContext; + // Admin-authored templates (WorkoutCategories, and WorkoutPlans/Items/Exercises where IsTemplate=true). + private readonly CentralDbContext _central; + + public WorkoutRepository(TenantDbContext dbContext, CentralDbContext central) + { + _dbContext = dbContext; + _central = central; + } + + // Workout Category operations -- global lookup, admin-owned, always central. + public async Task> GetWorkoutCategoryListAsync(CancellationToken ct = default) + { + return await _central.WorkoutCategories + .AsNoTracking() + .Where(wc => wc.IsActive) + .OrderBy(wc => wc.WorkoutCategoryName) + .Select(wc => new WorkoutCategoryDto + { + WorkoutCategoryId = wc.WorkoutCategoryId, + Name = wc.WorkoutCategoryName, + Description = wc.Description, + IsActive = wc.IsActive, + CreateDate = wc.CreateDate + }) + .ToListAsync(ct); + } + + // Categories are central; the plan row is tenant-side with no enforceable cross-DB + // FK, so the id has to be checked here rather than left to the database. + public Task WorkoutCategoryExistsAsync(Guid workoutCategoryId, CancellationToken ct = default) + { + return _central.WorkoutCategories + .AsNoTracking() + .AnyAsync(wc => wc.WorkoutCategoryId == workoutCategoryId && wc.IsActive, ct); + } + + public async Task CreateWorkoutCategoryAsync(WorkoutCategory workoutCategory, CancellationToken ct = default) + { + await _central.WorkoutCategories.AddAsync(workoutCategory, ct); + return workoutCategory; + } + + // Workout Plan operations + public async Task> GetWorkoutPlanListAsync(string userType, Guid clientId, CancellationToken ct = default) + { + // A customer browsing sees admin-authored templates (central); the coach/owner + // dashboard only manages this gym's own custom plans (tenant) -- disjoint sets + // post-split, never merged in one query. + if (userType == "Customer") + { + return await _central.WorkoutPlans + .AsNoTracking() + .Include(wp => wp.WorkoutCategory) + .Where(wp => wp.IsActive && wp.IsTemplate) + .OrderByDescending(wp => wp.CreateDate) + .Select(wp => new WorkoutPlanDto + { + WorkoutPlanId = wp.WorkoutPlanId, + Name = wp.WorkoutPlanName, + Description = wp.Description, + IsTemplate = wp.IsTemplate, + WorkoutCategoryId = wp.FKWorkoutCategoryId, + WorkoutCategoryName = wp.WorkoutCategory != null ? wp.WorkoutCategory.WorkoutCategoryName : "", + ClientId = wp.FKClientId, + ClientName = null, + IsActive = wp.IsActive, + CreateDate = wp.CreateDate, + AssignedCustomers = new List() + }) + .ToListAsync(ct); + } + + // Categories live centrally, so the tenant query can't join them -- resolve the + // names in a second round-trip and stitch them on below. + var categoryNames = await _central.WorkoutCategories + .AsNoTracking() + .ToDictionaryAsync(wc => wc.WorkoutCategoryId, wc => wc.WorkoutCategoryName, ct); + + var tenantPlans = await _dbContext.WorkoutPlans + .AsNoTracking() + .Include(wp => wp.CustomerWorkouts.Where(cw => cw.IsActive)) + .ThenInclude(cw => cw.TenantCustomerLink) + .Where(wp => wp.IsActive && !wp.IsTemplate && wp.FKClientId == clientId) + .OrderByDescending(wp => wp.CreateDate) + .Select(wp => new WorkoutPlanDto + { + WorkoutPlanId = wp.WorkoutPlanId, + Name = wp.WorkoutPlanName, + Description = wp.Description, + IsTemplate = wp.IsTemplate, + WorkoutCategoryId = wp.FKWorkoutCategoryId, + WorkoutCategoryName = "", + ClientId = wp.FKClientId, + ClientName = null, + IsActive = wp.IsActive, + CreateDate = wp.CreateDate, + AssignedCustomers = wp.CustomerWorkouts + .Where(cw => cw.IsActive) + .Select(cw => new CustomerWorkoutDto + { + CustomerWorkoutId = cw.CustomerWorkoutId, + CustomerId = cw.FKCustomerId, + CustomerName = cw.TenantCustomerLink != null ? cw.TenantCustomerLink.DisplayName : null, + WorkoutPlanId = cw.FKWorkoutPlanId, + IsActive = cw.IsActive, + IsFollowing = cw.IsFollowing, + AssignedDate = cw.AssignedDate, + CompletedDate = cw.CompletedDate, + Notes = cw.Notes + }).ToList() + }) + .ToListAsync(ct); + + foreach (var plan in tenantPlans) + { + plan.WorkoutCategoryName = + categoryNames.TryGetValue(plan.WorkoutCategoryId, out var name) ? name : ""; + } + + return tenantPlans; + } + + public async Task GetWorkoutPlanByIdAsync(Guid id, CancellationToken ct = default) + { + // The id could belong to either bucket (an admin template or a client-owned + // plan) -- try central first, fall back to tenant. + var centralPlan = await _central.WorkoutPlans + .AsNoTracking() + .Include(wp => wp.WorkoutCategory) + .Include(wp => wp.WorkoutItems) + .ThenInclude(wi => wi.WorkoutExercises) + .FirstOrDefaultAsync(wp => wp.WorkoutPlanId == id && wp.IsActive, ct); + if (centralPlan != null) + return MapDetail(centralPlan, Array.Empty()); + + var tenantPlan = await _dbContext.WorkoutPlans + .AsNoTracking() + .Include(wp => wp.CustomerWorkouts.Where(cw => cw.IsActive)) + .ThenInclude(cw => cw.TenantCustomerLink) + .Include(wp => wp.WorkoutItems) + .ThenInclude(wi => wi.WorkoutExercises) + .FirstOrDefaultAsync(wp => wp.WorkoutPlanId == id && wp.IsActive, ct); + if (tenantPlan == null) + return null; + + var detail = MapDetail(tenantPlan, tenantPlan.CustomerWorkouts.Where(cw => cw.IsActive).ToList()); + // Category is central-only, so it can't be Include()d from the tenant context. + detail.WorkoutCategoryName = await _central.WorkoutCategories + .AsNoTracking() + .Where(wc => wc.WorkoutCategoryId == tenantPlan.FKWorkoutCategoryId) + .Select(wc => wc.WorkoutCategoryName) + .FirstOrDefaultAsync(ct) ?? ""; + return detail; + } + + private static WorkoutPlanDetailDto MapDetail(WorkoutPlan workoutPlan, IReadOnlyCollection assigned) + { + return new WorkoutPlanDetailDto + { + WorkoutPlanId = workoutPlan.WorkoutPlanId, + Name = workoutPlan.WorkoutPlanName, + Description = workoutPlan.Description, + IsTemplate = workoutPlan.IsTemplate, + WorkoutCategoryId = workoutPlan.FKWorkoutCategoryId, + WorkoutCategoryName = workoutPlan.WorkoutCategory != null ? workoutPlan.WorkoutCategory.WorkoutCategoryName : "", + ClientId = workoutPlan.FKClientId, + ClientName = null, + IsActive = workoutPlan.IsActive, + CreateDate = workoutPlan.CreateDate, + WorkoutItems = workoutPlan.WorkoutItems + .Where(wi => wi.IsActive) + .Select(MapWorkoutItem) + .ToList(), + AssignedCustomers = assigned + .Select(cw => new CustomerWorkoutDto + { + CustomerWorkoutId = cw.CustomerWorkoutId, + CustomerId = cw.FKCustomerId, + CustomerName = cw.TenantCustomerLink?.DisplayName, + WorkoutPlanId = cw.FKWorkoutPlanId, + IsActive = cw.IsActive, + IsFollowing = cw.IsFollowing, + AssignedDate = cw.AssignedDate, + CompletedDate = cw.CompletedDate, + Notes = cw.Notes + }).ToList() + }; + } + + // Shared WorkoutItem -> WorkoutItemDto mapping (including structured exercises) for the + // client/coach-facing read paths, which operate on already-materialized entity graphs + // rather than server-translated queries. + private static WorkoutItemDto MapWorkoutItem(WorkoutItem wi) + { + var exerciseDtos = wi.WorkoutExercises + .Where(we => we.IsActive) + .OrderBy(we => we.SortOrder) + .Select(we => new WorkoutExerciseDto + { + WorkoutExerciseId = we.WorkoutExerciseId, + Name = we.Name, + MuscleGroup = we.MuscleGroup, + Sets = we.Sets, + Reps = we.Reps, + Description = we.Description, + BreathIn = we.BreathIn, + BreathOut = we.BreathOut, + VideoUrl = we.VideoUrl, + SortOrder = we.SortOrder, + IsCompleted = false + }) + .ToList(); + + return new WorkoutItemDto + { + WorkoutItemId = wi.WorkoutItemId, + ExerciseDay = wi.ExerciseDay, + Description = wi.Description, + Exercises = wi.Exercises, + HowToDo = wi.HowToDo, + DoURL = wi.DoURL, + Duration = wi.Duration, + WormUp = wi.WormUp, + Cooldown = wi.Cooldown, + WorkoutPlanId = wi.FKWorkoutPlanId, + IsActive = wi.IsActive, + CreateDate = wi.CreateDate, + UpdatedAt = wi.UpdatedAt, + WorkoutExercises = exerciseDtos, + TotalExercises = exerciseDtos.Count, + CompletedExercises = 0, + IsCompleted = false + }; + } + + public async Task> GetWorkoutPlansByCustomerIdAsync(Guid customerId, CancellationToken ct = default) + { + // CustomerWorkout (tenant) records which plans this customer follows at this gym; + // each followed plan is resolved from whichever bucket it actually lives in. + var follows = await _dbContext.CustomerWorkouts + .AsNoTracking() + .Where(cw => cw.FKCustomerId == customerId && cw.IsActive) + .OrderByDescending(cw => cw.AssignedDate) + .Select(cw => cw.FKWorkoutPlanId) + .Distinct() + .ToListAsync(ct); + + var result = new List(); + foreach (var workoutPlanId in follows) + { + var detail = await GetWorkoutPlanByIdAsync(workoutPlanId, ct); + if (detail != null && detail.IsActive) + result.Add(detail); + } + return result; + } + + public async Task GetWorkoutPlanEntityByIdAsync(Guid id, CancellationToken ct = default) + { + return await _dbContext.WorkoutPlans + .FirstOrDefaultAsync(wp => wp.WorkoutPlanId == id && wp.IsActive, ct); + } + + public async Task CreateWorkoutPlanAsync(WorkoutPlan workoutPlan, CancellationToken ct = default) + { + await _dbContext.WorkoutPlans.AddAsync(workoutPlan, ct); + return workoutPlan; + } + + public async Task UpdateWorkoutPlanAsync(WorkoutPlan workoutPlan, CancellationToken ct = default) + { + _dbContext.WorkoutPlans.Update(workoutPlan); + return workoutPlan; + } + + public async Task DeleteWorkoutPlanAsync(Guid id, CancellationToken ct = default) + { + var workoutPlan = await _dbContext.WorkoutPlans.FindAsync(new object[] { id }, ct); + + if (workoutPlan == null) + { + return false; + } + + workoutPlan.IsActive = false; + _dbContext.WorkoutPlans.Update(workoutPlan); + + return true; + } + + public async Task GetWorkoutItemByIdAsync(Guid id, CancellationToken ct = default) + { + return await _dbContext.WorkoutItems + .FirstOrDefaultAsync(wi => wi.WorkoutItemId == id && wi.IsActive, ct); + } + + // Workout Item operations + public async Task> GetWorkoutItemsByWorkoutPlanIdAsync(Guid workoutPlanId, CancellationToken ct = default) + { + var items = await _dbContext.WorkoutItems + .AsNoTracking() + .Include(wi => wi.WorkoutExercises) + .Where(wi => wi.FKWorkoutPlanId == workoutPlanId && wi.IsActive) + .OrderBy(wi => wi.CreateDate) + .ToListAsync(ct); + + return items.Select(MapWorkoutItem).ToList(); + } + + public async Task CreateWorkoutItemAsync(WorkoutItem workoutItem, CancellationToken ct = default) + { + await _dbContext.WorkoutItems.AddAsync(workoutItem, ct); + return workoutItem; + } + + public async Task UpdateWorkoutItemAsync(WorkoutItem workoutItem, CancellationToken ct = default) + { + _dbContext.WorkoutItems.Update(workoutItem); + return workoutItem; + } + + public async Task DeleteWorkoutItemAsync(Guid id, CancellationToken ct = default) + { + var workoutItem = await _dbContext.WorkoutItems.FindAsync(new object[] { id }, ct); + if (workoutItem != null) + { + // Soft delete by setting IsActive to false + workoutItem.IsActive = false; + _dbContext.WorkoutItems.Update(workoutItem); + } + } + + // Customer Workout Assignment operations + // Assigning a plan puts the customer on it the same way self-follow does: only one + // plan can be "followed" at a time, and an assigned plan must show up under the + // customer's Followed tab immediately. + public async Task AssignWorkoutToCustomerAsync(CustomerWorkout customerWorkout, CancellationToken ct = default) + { + var currentFollow = await _dbContext.CustomerWorkouts + .FirstOrDefaultAsync(cw => cw.FKCustomerId == customerWorkout.FKCustomerId && cw.IsFollowing, ct); + + if (currentFollow != null && currentFollow.FKWorkoutPlanId != customerWorkout.FKWorkoutPlanId) + { + currentFollow.IsFollowing = false; + _dbContext.CustomerWorkouts.Update(currentFollow); + } + + var existing = await _dbContext.CustomerWorkouts + .FirstOrDefaultAsync(cw => cw.FKCustomerId == customerWorkout.FKCustomerId && cw.FKWorkoutPlanId == customerWorkout.FKWorkoutPlanId, ct); + + if (existing != null) + { + existing.IsFollowing = true; + existing.IsActive = true; + existing.AssignedDate = customerWorkout.AssignedDate; + existing.Notes = customerWorkout.Notes; + _dbContext.CustomerWorkouts.Update(existing); + return existing; + } + + customerWorkout.IsFollowing = true; + await _dbContext.CustomerWorkouts.AddAsync(customerWorkout, ct); + return customerWorkout; + } + + public async Task UnassignWorkoutFromCustomerAsync(Guid customerWorkoutId, CancellationToken ct = default) + { + var customerWorkout = await _dbContext.CustomerWorkouts.FindAsync(new object[] { customerWorkoutId }, ct); + + if (customerWorkout == null) + { + return false; + } + + // Soft delete by setting IsActive to false + customerWorkout.IsActive = false; + _dbContext.CustomerWorkouts.Update(customerWorkout); + + return true; + } + + // A followed plan id may name a central template or a tenant plan, so names are + // resolved from both buckets rather than through a (now un-enforced) navigation. + private async Task> ResolvePlanNamesAsync( + IReadOnlyCollection planIds, CancellationToken ct) + { + if (planIds.Count == 0) + return new Dictionary(); + + var names = await _dbContext.WorkoutPlans + .AsNoTracking() + .Where(wp => planIds.Contains(wp.WorkoutPlanId)) + .ToDictionaryAsync(wp => wp.WorkoutPlanId, wp => wp.WorkoutPlanName, ct); + + var missing = planIds.Where(id => !names.ContainsKey(id)).ToList(); + if (missing.Count > 0) + { + var centralNames = await _central.WorkoutPlans + .AsNoTracking() + .Where(wp => missing.Contains(wp.WorkoutPlanId)) + .ToDictionaryAsync(wp => wp.WorkoutPlanId, wp => wp.WorkoutPlanName, ct); + foreach (var pair in centralNames) + names[pair.Key] = pair.Value; + } + + return names; + } + + private async Task> BuildCustomerWorkoutDtosAsync( + IQueryable query, CancellationToken ct) + { + var rows = await query + .AsNoTracking() + .Include(cw => cw.TenantCustomerLink) + .OrderByDescending(cw => cw.AssignedDate) + .Select(cw => new CustomerWorkoutDto + { + CustomerWorkoutId = cw.CustomerWorkoutId, + CustomerId = cw.FKCustomerId, + CustomerName = cw.TenantCustomerLink != null ? cw.TenantCustomerLink.DisplayName : null, + WorkoutPlanId = cw.FKWorkoutPlanId, + WorkoutPlanName = null, + IsActive = cw.IsActive, + IsFollowing = cw.IsFollowing, + AssignedDate = cw.AssignedDate, + CompletedDate = cw.CompletedDate, + Notes = cw.Notes + }) + .ToListAsync(ct); + + var names = await ResolvePlanNamesAsync( + rows.Select(r => r.WorkoutPlanId).Distinct().ToList(), ct); + + foreach (var row in rows) + row.WorkoutPlanName = names.TryGetValue(row.WorkoutPlanId, out var name) ? name : null; + + return rows; + } + + public Task> GetCustomerWorkoutsByPlanIdAsync(Guid workoutPlanId, CancellationToken ct = default) + => BuildCustomerWorkoutDtosAsync( + _dbContext.CustomerWorkouts.Where(cw => cw.FKWorkoutPlanId == workoutPlanId && cw.IsActive), ct); + + public Task> GetCustomerWorkoutsByCustomerIdAsync(Guid customerId, CancellationToken ct = default) + => BuildCustomerWorkoutDtosAsync( + _dbContext.CustomerWorkouts.Where(cw => cw.FKCustomerId == customerId && cw.IsActive), ct); + } +} diff --git a/Services/AccessDeviceService.cs b/Services/AccessDeviceService.cs new file mode 100644 index 0000000..9513ebf --- /dev/null +++ b/Services/AccessDeviceService.cs @@ -0,0 +1,803 @@ +using Fitness_core_BE.DTOs; +using Fitness_core_BE.DTOs.AccessDevice; +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace Fitness_core_BE.Services +{ + /// + /// Owns the cloud-side desired state for physical access. User identity is scoped by + /// client in AccessProfile; terminal-specific state lives in AccessDeviceBinding. + /// Fingerprint template bytes never leave the terminal. + /// + public class AccessDeviceService : IAccessDeviceService + { + private readonly IUnitOfWork _UoW; + private readonly IAccessDeviceRepo _accessDeviceRepo; + private readonly IAttendanceRepository _attendanceRepo; + private readonly IDeviceCommandRepo _deviceCommandRepo; + private readonly IPairingCodeRepo _pairingCodeRepo; + + public AccessDeviceService( + IUnitOfWork unitOfWork, + IAccessDeviceRepo accessDeviceRepo, + IAttendanceRepository attendanceRepo, + IDeviceCommandRepo deviceCommandRepo, + IPairingCodeRepo pairingCodeRepo) + { + _UoW = unitOfWork; + _accessDeviceRepo = accessDeviceRepo; + _attendanceRepo = attendanceRepo; + _deviceCommandRepo = deviceCommandRepo; + _pairingCodeRepo = pairingCodeRepo; + } + + public async Task AddDevice(object payload) + { + await _UoW.BeginAsync(); + var data = DeserializePayload.Deserialize(payload); + try + { + var device = new AccessDevice + { + DeviceId = Guid.NewGuid(), + DeviceName = DataExtractor.GetRequiredString(data, "DeviceName"), + FKClientId = DataExtractor.GetRequiredGuid(data, "FKClientId") + }; + + await _accessDeviceRepo.AddDeviceAsync(device); + await _UoW.CommitAsync(); + return new { Message = "Device added", device.DeviceId }; + } + catch + { + await _UoW.RollbackAsync(); + throw; + } + } + + public async Task RemoveDevice(object payload) + { + await _UoW.BeginAsync(); + var data = DeserializePayload.Deserialize(payload); + try + { + var clientId = DataExtractor.GetRequiredGuid(data, "clientId"); + var result = await _accessDeviceRepo.RemoveDeviceAsync(clientId); + await _UoW.CommitAsync(); + return new { Message = $"Device {result.DeviceId} disabled", result.DeviceId }; + } + catch + { + await _UoW.RollbackAsync(); + throw; + } + } + + public async Task GeneratePairingCode(object payload) + { + var data = DeserializePayload.Deserialize(payload); + var clientId = DataExtractor.GetRequiredGuid(data, "clientId"); + var pairingCode = new PairingCode + { + Id = Guid.NewGuid(), + Code = GenerateShortCode(), + FKClientId = clientId, + ExpiresAt = DateTime.UtcNow.AddMinutes(15), + IsUsed = false, + CreatedAt = DateTime.UtcNow + }; + + await _UoW.BeginAsync(); + try + { + await _pairingCodeRepo.AddAsync(pairingCode); + await _UoW.CommitAsync(); + return new { pairingCode.Code, pairingCode.ExpiresAt }; + } + catch + { + await _UoW.RollbackAsync(); + throw; + } + } + + /// + /// Compatibility entry point for old admin callers. New flows create an + /// AccessProfile automatically when the customer/staff record is created. + /// + public async Task AddUser(CustomerAccess requested) + { + var device = await _accessDeviceRepo.GetDeviceByDId(requested.FKDeviceId); + var customer = await _accessDeviceRepo + .GetCustomerByIdAsync(requested.FKCustomerId) + ?? throw new NotFoundException("Customer not found."); + + await _UoW.BeginAsync(); + try + { + var profile = await EnsureAccessProfileAsync( + device.FKClientId, customer.FKUserId, "Customer", + requested.Name ?? customer.CusName ?? string.Empty, queueProvisioning: false); + var binding = await EnsureBindingAsync(profile, device); + await QueueProfileCommandAsync(profile, binding, "UpsertPerson"); + await _UoW.CommitAsync(); + + return new + { + accessProfileId = profile.AccessProfileId, + accessNumber = binding.DeviceEmployeeNo, + Message = "Access user queued for the on-prem agent." + }; + } + catch + { + await _UoW.RollbackAsync(); + throw; + } + } + + public async Task EnsureAccessProfileAsync( + Guid clientId, + Guid userId, + string personType, + string displayName, + bool queueProvisioning = true) + { + var profile = await _accessDeviceRepo.GetAccessProfileAsync(clientId, userId); + + if (profile == null) + { + profile = new AccessProfile + { + FKClientId = clientId, + FKUserId = userId, + PersonType = personType, + DisplayName = displayName, + DesiredAccessState = "Enabled", + DesiredVersion = 1, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + await _accessDeviceRepo.AddAccessProfileAsync(profile); + // AccessProfileId is the stable numeric terminal employee number. + await _UoW.SaveChangesAsync(); + } + else + { + profile.PersonType = personType; + profile.DisplayName = displayName; + profile.UpdatedAt = DateTime.UtcNow; + } + + if (queueProvisioning) + await QueueProvisioningForActiveDevicesAsync(profile); + + return profile; + } + + public async Task ProvisionProfilesToDeviceAsync(AccessDevice device) + { + var profiles = await _accessDeviceRepo + .GetAccessProfilesByClientAsync(device.FKClientId); + + foreach (var profile in profiles) + { + var binding = await EnsureBindingAsync(profile, device); + if (binding.ProvisionStatus != "Synced") + await QueueProfileCommandAsync(profile, binding, "UpsertPerson"); + } + } + + public async Task SetUserAccessAsync(Guid clientId, Guid userId, bool enabled, string? reason) + { + await _UoW.BeginAsync(); + try + { + var profile = await _accessDeviceRepo + .GetAccessProfileAsync(clientId, userId) + ?? throw new NotFoundException("No access profile exists for this user at this gym."); + + profile.DesiredAccessState = enabled ? "Enabled" : "Disabled"; + profile.DesiredVersion++; + profile.LockReason = enabled ? null : reason; + profile.LockedAt = enabled ? null : DateTime.UtcNow; + profile.UpdatedAt = DateTime.UtcNow; + + // Include terminals added after the person was originally onboarded. + await QueueProvisioningForActiveDevicesAsync(profile); + + foreach (var binding in profile.DeviceBindings) + { + if (!await IsDeviceCommandableAsync(binding.FKDeviceId)) + continue; + binding.ProvisionStatus = "Pending"; + binding.LastError = null; + binding.UpdatedAt = DateTime.UtcNow; + await SupersedePendingStateCommandsAsync(binding.AccessDeviceBindingId); + await QueueProfileCommandAsync(profile, binding, "SetAccessState"); + } + + await _UoW.CommitAsync(); + return (await GetAccessStatusAsync(clientId, userId))!; + } + catch + { + await _UoW.RollbackAsync(); + throw; + } + } + + public async Task SetCustomerAccessAsync( + Guid clientId, Guid customerId, bool enabled, string? reason) + { + var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId) + ?? throw new NotFoundException("Customer not found."); + var belongsToGym = await _accessDeviceRepo + .IsActiveCustomerMemberAsync(clientId, customerId); + if (!belongsToGym) + throw new ForbiddenException("This customer does not belong to your gym."); + + var status = await SetUserAccessAsync(clientId, customer.FKUserId, enabled, reason); + + // A couple/group plan is one shared entitlement, so the whole party follows the + // member that was acted on -- locking only the primary would leave the partner + // badging in on a plan that is locked, and unlocking only the primary would leave + // the partner stranded after the reason for the lock is gone. + var coMemberIds = await _accessDeviceRepo + .GetCoMembersOnSharedSubscriptionsAsync(clientId, customerId); + + foreach (var coMemberId in coMemberIds) + { + var coMember = await _accessDeviceRepo.GetCustomerByIdAsync(coMemberId); + if (coMember == null) + continue; + + // Best-effort: a co-member without an access profile yet (never onboarded to + // a device) must not fail the lock that was actually requested. + try + { + await SetUserAccessAsync(clientId, coMember.FKUserId, enabled, reason); + } + catch (NotFoundException) + { + } + } + + return status; + } + + public async Task SetCustomerFacePhotoAsync( + Guid clientId, Guid customerId, string base64Jpeg) + { + // Validate before opening a transaction: a rejected photo should cost nothing. + var photo = FacePhotoValidator.ValidateBase64(base64Jpeg); + + var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId) + ?? throw new NotFoundException("Customer not found."); + var belongsToGym = await _accessDeviceRepo + .IsActiveCustomerMemberAsync(clientId, customerId); + if (!belongsToGym) + throw new ForbiddenException("This customer does not belong to your gym."); + + await _UoW.BeginAsync(); + try + { + var profile = await _accessDeviceRepo + .GetAccessProfileAsync(clientId, customer.FKUserId) + ?? throw new NotFoundException( + "No access profile exists for this member at this gym."); + + profile.FacePhotoStatus = "Pending"; + profile.FacePhotoError = null; + profile.FacePhotoUpdatedAt = DateTime.UtcNow; + profile.DesiredVersion++; + profile.UpdatedAt = DateTime.UtcNow; + + // Include terminals added after the member was originally onboarded. + await QueueProvisioningForActiveDevicesAsync(profile); + + var queued = 0; + var payload = Convert.ToBase64String(photo.Bytes); + foreach (var binding in profile.DeviceBindings) + { + if (!await IsDeviceCommandableAsync(binding.FKDeviceId)) + continue; + await SupersedePendingFacePhotoCommandsAsync(binding.AccessDeviceBindingId); + await QueueFacePhotoCommandAsync(profile, binding, payload); + queued++; + } + + if (queued == 0) + { + // Nothing can carry the photo to a terminal, so don't leave the member + // sitting on "Pending" forever with no command in flight. + profile.FacePhotoStatus = "Failed"; + profile.FacePhotoError = + "No reachable access terminal is currently paired with this gym."; + } + + await _UoW.CommitAsync(); + + return new + { + accessProfileId = profile.AccessProfileId, + facePhotoStatus = profile.FacePhotoStatus, + queuedToDevices = queued, + width = photo.Width, + height = photo.Height, + sizeBytes = photo.Bytes.Length + }; + } + catch + { + await _UoW.RollbackAsync(); + throw; + } + } + + // Kept separate from QueueProfileCommandAsync: the face payload is large and is + // deliberately purged after the terminal confirms, which the person/state commands + // must never be. + private async Task QueueFacePhotoCommandAsync( + AccessProfile profile, AccessDeviceBinding binding, string base64Jpeg) + { + var payloadJson = JsonSerializer.Serialize(new + { + bindingId = binding.AccessDeviceBindingId, + accessProfileId = profile.AccessProfileId, + employeeNo = binding.DeviceEmployeeNo, + name = profile.DisplayName, + faceJpegBase64 = base64Jpeg, + desiredVersion = profile.DesiredVersion + }); + + await _deviceCommandRepo.AddAsync(new DeviceCommand + { + Id = Guid.NewGuid(), + FKClientId = profile.FKClientId, + FKDeviceId = binding.FKDeviceId, + FKAccessDeviceBindingId = binding.AccessDeviceBindingId, + CommandType = "UpsertFacePhoto", + PayloadJson = payloadJson, + DesiredVersion = profile.DesiredVersion, + Status = "Pending", + MaxAttempts = 3, + NextAttemptAt = DateTime.UtcNow, + CreatedAt = DateTime.UtcNow + }); + } + + // A newer photo replaces an older one that has not reached the device yet, so the + // stale payload is dropped rather than left queued (and holding an image). + private async Task SupersedePendingFacePhotoCommandsAsync(Guid bindingId) + { + var stale = await _deviceCommandRepo + .GetPendingFacePhotoCommandsAsync(bindingId); + foreach (var command in stale) + { + command.Status = "Superseded"; + command.PayloadJson = string.Empty; + command.ProcessedAt = DateTime.UtcNow; + } + } + + public async Task SetStaffAccessAsync( + Guid clientId, Guid staffId, bool enabled, string? reason) + { + var staff = await _accessDeviceRepo.GetStaffByIdAsync(clientId, staffId) + ?? throw new NotFoundException("Staff member not found in this gym."); + return await SetUserAccessAsync(clientId, staff.FKUserId, enabled, reason); + } + + public async Task WhiteListMember(Guid customerId) + { + var (clientId, userId) = await ResolveCustomerAccessOwnerAsync(customerId); + await SetUserAccessAsync(clientId, userId, enabled: true, reason: null); + return true; + } + + public async Task BlackListMember(Guid customerId) + { + var (clientId, userId) = await ResolveCustomerAccessOwnerAsync(customerId); + await SetUserAccessAsync(clientId, userId, enabled: false, reason: "Manual"); + return true; + } + + public async Task LockCustomer(Guid customerId, string reason) + { + var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId); + if (customer == null) + return false; + + var (clientId, userId) = await ResolveCustomerAccessOwnerAsync(customerId); + await SetUserAccessAsync(clientId, userId, enabled: false, reason); + + customer.IsLocked = true; + customer.LockReason = reason; + customer.LockedAt = DateTime.UtcNow; + await _UoW.SaveChangesAsync(); + return true; + } + + public async Task UnlockCustomer(Guid customerId) + { + var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId); + if (customer == null) + return false; + + var (clientId, userId) = await ResolveCustomerAccessOwnerAsync(customerId); + await SetUserAccessAsync(clientId, userId, enabled: true, reason: null); + + customer.IsLocked = false; + customer.LockReason = null; + customer.LockedAt = null; + await _UoW.SaveChangesAsync(); + return true; + } + + public Task UpdateDeviceProfile(Customer customer) => + throw new NotImplementedException(); + + public async Task SaveAttendanceRecords(Guid clientId, List attendanceList) + { + if (attendanceList.Count == 0) + return 0; + + // The source key is stable across JSON DateTime kind conversion and + // database round-trips. It also gives the database a final idempotency + // guard when an agent retries after an ambiguous HTTP response. + var keyedEvents = attendanceList + .OrderBy(a => a.EventTime) + .Select(dto => new + { + Dto = dto, + SourceEventKey = BuildSourceEventKey( + clientId, dto.DeviceId, dto.DeviceEmployeeNo, dto.EventTime, dto.EventType) + }) + .ToList(); + var candidateKeys = keyedEvents + .Select(e => e.SourceEventKey) + .Distinct() + .ToList(); + var existingKeys = await _attendanceRepo.GetSourceEventKeysAsync(candidateKeys); + + var newRecords = new List(); + var seenKeys = new HashSet(existingKeys); + foreach (var keyedEvent in keyedEvents) + { + if (!seenKeys.Add(keyedEvent.SourceEventKey)) + continue; + + var dto = keyedEvent.Dto; + newRecords.Add(new Attendance + { + AttendanceId = Guid.NewGuid(), + FKClientId = clientId, + FKDeviceId = dto.DeviceId, + FKCustomerId = dto.CustomerId, + FKUserId = dto.UserId, + FKAccessProfileId = dto.AccessProfileId, + DeviceUserId = dto.DeviceUserId, + DeviceEmployeeNo = dto.DeviceEmployeeNo, + SourceEventKey = keyedEvent.SourceEventKey, + EventTime = dto.EventTime, + EventType = dto.EventType + }); + } + + if (newRecords.Count == 0) + return 0; + + await _UoW.BeginAsync(); + try + { + await _attendanceRepo.AddAttendancesAsync(newRecords); + await _UoW.CommitAsync(); + return newRecords.Count; + } + catch + { + await _UoW.RollbackAsync(); + // The agent advances its per-device watermark only after an HTTP success. + // Surface persistence failures so it retries instead of losing events. + throw; + } + } + + public Task> GetUnlinkedFingerprintsAsync(Guid clientId) => + _accessDeviceRepo.GetUnlinkedEnrollmentsByClientAsync(clientId); + + public async Task LinkFingerprintAsync( + Guid clientId, Guid customerId, Guid unlinkedEnrollmentId) + { + var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId) + ?? throw new NotFoundException("Customer not found."); + + var belongsToGym = await _accessDeviceRepo + .IsActiveCustomerMemberAsync(clientId, customerId); + if (!belongsToGym) + throw new ForbiddenException("This customer does not belong to your gym."); + + return await LinkDevicePersonAsync( + clientId, customer.FKUserId, "Customer", + customer.CusName ?? string.Empty, unlinkedEnrollmentId); + } + + public async Task LinkDevicePersonAsync( + Guid clientId, + Guid userId, + string personType, + string displayName, + Guid unlinkedEnrollmentId) + { + var enrollment = await _accessDeviceRepo + .GetUnlinkedEnrollmentByIdAsync(unlinkedEnrollmentId) + ?? throw new NotFoundException("Unlinked device person not found."); + + if (enrollment.LinkedAt != null) + throw new BadRequestException("This device person has already been linked."); + if (enrollment.AccessDevice.FKClientId != clientId) + throw new ForbiddenException("This device person does not belong to your gym."); + + var employeeNo = NormalizeEmployeeNo(enrollment); + var occupied = await _accessDeviceRepo.IsDeviceEmployeeNoOccupiedAsync( + enrollment.FKDeviceId, employeeNo, userId); + if (occupied) + throw new BadRequestException($"Device user {employeeNo} is already linked to another person."); + + await _UoW.BeginAsync(); + try + { + var profile = await EnsureAccessProfileAsync( + clientId, userId, personType, displayName, queueProvisioning: false); + var binding = await _accessDeviceRepo.GetBindingAsync( + profile.AccessProfileId, enrollment.FKDeviceId); + + if (binding == null) + { + binding = new AccessDeviceBinding + { + AccessDeviceBindingId = Guid.NewGuid(), + FKAccessProfileId = profile.AccessProfileId, + FKDeviceId = enrollment.FKDeviceId, + CreatedAt = DateTime.UtcNow + }; + await _accessDeviceRepo.AddBindingAsync(binding); + profile.DeviceBindings.Add(binding); + } + + binding.DeviceEmployeeNo = employeeNo; + binding.DeviceReportedName = enrollment.DeviceReportedName; + binding.FingerprintCount = enrollment.FingerprintCount; + binding.ProvisionStatus = "Synced"; + binding.ActualAccessState = IsBlocked(enrollment.DeviceUserType) ? "Disabled" : "Enabled"; + binding.LastSyncedAt = DateTime.UtcNow; + binding.LastError = null; + binding.UpdatedAt = DateTime.UtcNow; + + var linkedAt = DateTime.UtcNow; + var duplicateDiscoveries = + await _accessDeviceRepo.GetDuplicateUnlinkedEnrollmentsAsync( + enrollment.FKDeviceId, + employeeNo, + enrollment.DeviceUserId); + foreach (var discovered in duplicateDiscoveries) + { + discovered.LinkedAt = linkedAt; + discovered.FKAccessProfileId = profile.AccessProfileId; + } + + // Reconcile all other active terminals for the same gym. + await QueueProvisioningForActiveDevicesAsync(profile); + await _UoW.CommitAsync(); + return profile; + } + catch + { + await _UoW.RollbackAsync(); + throw; + } + } + + public async Task LinkStaffFingerprintAsync( + Guid clientId, Guid staffId, Guid unlinkedEnrollmentId) + { + var staff = await _accessDeviceRepo.GetStaffByIdAsync(clientId, staffId) + ?? throw new NotFoundException("Staff member not found in this gym."); + return await LinkDevicePersonAsync( + clientId, staff.FKUserId, "Staff", + staff.User?.UserName ?? string.Empty, unlinkedEnrollmentId); + } + + public async Task GetAccessStatusAsync(Guid clientId, Guid userId) + { + var profile = await _accessDeviceRepo + .GetAccessStatusProfileAsync(clientId, userId); + if (profile == null) + return null; + + var bindings = profile.DeviceBindings + .Where(b => b.AccessDevice.IsActive && b.AccessDevice.FKAgentId != null) + .ToList(); + var provisionStatus = bindings.Count == 0 + ? "NoDevice" + : bindings.Any(b => b.ProvisionStatus == "Failed") + ? "Failed" + : bindings.All(b => b.ProvisionStatus == "Synced") ? "Synced" : "Pending"; + var desired = profile.DesiredAccessState; + var actual = bindings.Count == 0 + ? "Unknown" + : bindings.All(b => b.ActualAccessState == desired) ? desired + : bindings.All(b => b.ActualAccessState == "Unknown") ? "Unknown" : "Mixed"; + + return new AccessStatusDto + { + AccessProfileId = profile.AccessProfileId, + AccessNumber = bindings.FirstOrDefault()?.DeviceEmployeeNo + ?? $"HF{profile.AccessProfileId}", + PersonType = profile.PersonType, + DesiredAccessState = desired, + ProvisionStatus = provisionStatus, + ActualAccessState = actual, + FingerprintCount = bindings.Count == 0 ? 0 : bindings.Max(b => b.FingerprintCount), + LastError = bindings.FirstOrDefault(b => !string.IsNullOrWhiteSpace(b.LastError))?.LastError, + LastSyncedAt = bindings.Max(b => b.LastSyncedAt), + DeviceCount = bindings.Count, + SyncedDeviceCount = bindings.Count(b => b.ProvisionStatus == "Synced") + }; + } + + private async Task QueueProvisioningForActiveDevicesAsync(AccessProfile profile) + { + var devices = (await _accessDeviceRepo + .GetDevicesByClientAsync(profile.FKClientId)) + .Where(device => device.FKAgentId != null) + .ToList(); + + foreach (var device in devices) + { + var binding = profile.DeviceBindings.FirstOrDefault(b => b.FKDeviceId == device.DeviceId) + ?? await EnsureBindingAsync(profile, device); + if (binding.ProvisionStatus != "Synced") + await QueueProfileCommandAsync(profile, binding, "UpsertPerson"); + } + } + + private async Task EnsureBindingAsync(AccessProfile profile, AccessDevice device) + { + var binding = profile.DeviceBindings.FirstOrDefault(b => b.FKDeviceId == device.DeviceId) + ?? await _accessDeviceRepo.GetBindingAsync( + profile.AccessProfileId, device.DeviceId); + if (binding != null) + return binding; + + binding = new AccessDeviceBinding + { + AccessDeviceBindingId = Guid.NewGuid(), + FKAccessProfileId = profile.AccessProfileId, + FKDeviceId = device.DeviceId, + // Namespace platform-managed terminal users away from locally-created + // numeric ids, which prevents overwriting an unlinked person by collision. + DeviceEmployeeNo = $"HF{profile.AccessProfileId}", + ProvisionStatus = "Pending", + ActualAccessState = "Unknown", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + await _accessDeviceRepo.AddBindingAsync(binding); + profile.DeviceBindings.Add(binding); + return binding; + } + + private async Task QueueProfileCommandAsync( + AccessProfile profile, AccessDeviceBinding binding, string commandType) + { + var alreadyQueued = await _deviceCommandRepo.IsQueuedAsync( + binding.AccessDeviceBindingId, + commandType, + profile.DesiredVersion); + if (alreadyQueued) + return; + + var payloadJson = JsonSerializer.Serialize(new + { + bindingId = binding.AccessDeviceBindingId, + accessProfileId = profile.AccessProfileId, + employeeNo = binding.DeviceEmployeeNo, + name = profile.DisplayName, + enabled = profile.DesiredAccessState == "Enabled", + desiredVersion = profile.DesiredVersion + }); + + await _deviceCommandRepo.AddAsync(new DeviceCommand + { + Id = Guid.NewGuid(), + FKClientId = profile.FKClientId, + FKDeviceId = binding.FKDeviceId, + FKAccessDeviceBindingId = binding.AccessDeviceBindingId, + CommandType = commandType, + PayloadJson = payloadJson, + DesiredVersion = profile.DesiredVersion, + Status = "Pending", + MaxAttempts = 5, + NextAttemptAt = DateTime.UtcNow, + CreatedAt = DateTime.UtcNow + }); + } + + private async Task SupersedePendingStateCommandsAsync(Guid bindingId) + { + var stale = await _deviceCommandRepo + .GetPendingStateCommandsAsync(bindingId); + foreach (var command in stale) + { + command.Status = "Superseded"; + command.ProcessedAt = DateTime.UtcNow; + } + } + + private Task IsDeviceCommandableAsync(Guid deviceId) => + _accessDeviceRepo.IsDeviceCommandableAsync(deviceId); + + private async Task<(Guid clientId, Guid userId)> ResolveCustomerAccessOwnerAsync(Guid customerId) + { + var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId) + ?? throw new NotFoundException("Customer not found."); + + var clientId = await _accessDeviceRepo + .GetLatestActiveClientIdForCustomerAsync(customerId); + if (!clientId.HasValue) + throw new NotFoundException("Customer has no active gym membership."); + return (clientId.Value, customer.FKUserId); + } + + private static string NormalizeEmployeeNo(UnlinkedDeviceEnrollment enrollment) => + !string.IsNullOrWhiteSpace(enrollment.DeviceEmployeeNo) + ? enrollment.DeviceEmployeeNo + : enrollment.DeviceUserId.ToString(); + + private static bool IsBlocked(string? userType) => + string.Equals(userType, "blackList", StringComparison.OrdinalIgnoreCase); + + private static string BuildSourceEventKey( + Guid clientId, + Guid deviceId, + string deviceEmployeeNo, + DateTime eventTime, + string eventType) + { + // MySQL DATETIME does not preserve DateTime.Kind. Formatting only the + // stored wall-clock fields keeps this key identical after reading it back. + var normalizedTime = eventTime.ToString( + "yyyy-MM-dd'T'HH:mm:ss.ffffff", CultureInfo.InvariantCulture); + var source = string.Join( + "|", + clientId.ToString("D"), + deviceId.ToString("D"), + deviceEmployeeNo.Trim(), + normalizedTime, + eventType.Trim()); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(source))) + .ToLowerInvariant(); + } + + private static string GenerateShortCode(int length = 8) + { + const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + var bytes = RandomNumberGenerator.GetBytes(length); + var chars = new char[length]; + for (var i = 0; i < length; i++) + chars[i] = alphabet[bytes[i] % alphabet.Length]; + return new string(chars); + } + } +} diff --git a/Services/ActivityLogService.cs b/Services/ActivityLogService.cs new file mode 100644 index 0000000..eb30775 --- /dev/null +++ b/Services/ActivityLogService.cs @@ -0,0 +1,98 @@ +using Fitness_core_BE.Models; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; + +namespace Fitness_core_BE.Services.ActivityLog +{ + public class ActivityLogService : IActivityLogService + { + private readonly IActivityLogRepository _activityLogRepository; + private readonly UserRoleHelper _userRoleHelper; + private readonly IUnitOfWork _uow; + + public ActivityLogService( + IActivityLogRepository activityLogRepository, + UserRoleHelper userRoleHelper, + IUnitOfWork uow) + { + _activityLogRepository = activityLogRepository; + _userRoleHelper = userRoleHelper; + _uow = uow; + } + + // To do: add services used, IP addresses, category as Error/ Action + + public async Task LogActivity(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + string description = data["description"].GetString()!; + + // Get userId from HttpContext ( for testing purposes - hardcoded) + Guid userId = Guid.Parse("08de9f89-4681-4a2f-8f37-6c1ce830ee6f"); + // Guid userId = Guid.Parse(httpContext.Items["UserId"]?.ToString()!); + + Guid? adminId = await _userRoleHelper.GetAdminId(userId); + bool isAdmin = adminId.HasValue; + + // Get user details (you might need to add a method to get user by Id) + var userName = await _activityLogRepository.GetUserNameAsync(userId); + + var log = new Fitness_core_BE.Models.ActivityLog + { + FKUserId = userId, + UserName = userName, + IsAdmin = isAdmin, + Description = description, + Timestamp = DateTime.UtcNow + }; + + await _activityLogRepository.AddActivityLogAsync(log); + await _uow.CommitAsync(); + + return log; + } + catch (Exception ex) + { + await _uow.RollbackAsync(); + Console.WriteLine($"Activity log failed: {ex.Message}"); + throw; + } + } + + public async Task GetActivityLogs(object payload, HttpContext httpContext) + { + try + { + var data = DeserializePayload.Deserialize(payload); + + string searchTerm = data.ContainsKey("searchTerm") && !string.IsNullOrEmpty(data["searchTerm"].GetString()) + ? data["searchTerm"].GetString() + : null; + + DateTime? startDate = data.ContainsKey("startDate") && !string.IsNullOrEmpty(data["startDate"].GetString()) + ? DateTime.Parse(data["startDate"].GetString()!) + : null; + + DateTime? endDate = data.ContainsKey("endDate") && !string.IsNullOrEmpty(data["endDate"].GetString()) + ? DateTime.Parse(data["endDate"].GetString()!) + : null; + + var logs = await _activityLogRepository.GetActivityLogsAsync( + searchTerm, + startDate, + endDate); + + return logs; + } + catch + { + throw; + } + } + } +} diff --git a/Services/AdminClientDirectoryService.cs b/Services/AdminClientDirectoryService.cs new file mode 100644 index 0000000..3932351 --- /dev/null +++ b/Services/AdminClientDirectoryService.cs @@ -0,0 +1,194 @@ +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using Fitness_core_BE.Models.IOs; +using System.Security.Cryptography; + +namespace Fitness_core_BE.Services +{ + public class AdminClientDirectoryService : IAdminClientDirectoryService + { + private readonly IAdminClientDirectoryRepository _repository; + private readonly IAgentRepo _agentRepo; + private readonly IAccessDeviceRepo _accessDeviceRepo; + private readonly IPairingCodeRepo _pairingCodeRepo; + private readonly IUnitOfWork _UoW; + private readonly IAuthService _authService; + + // Three missed 30-second health heartbeats marks an agent/device offline. + // Legacy agents without explicit health reports retain the old grace window. + private static readonly TimeSpan HealthOnlineWindow = TimeSpan.FromSeconds(90); + private static readonly TimeSpan LegacyOnlineWindow = TimeSpan.FromMinutes(5); + + public AdminClientDirectoryService( + IAdminClientDirectoryRepository repository, + IAgentRepo agentRepo, + IAccessDeviceRepo accessDeviceRepo, + IPairingCodeRepo pairingCodeRepo, + IUnitOfWork unitOfWork, + IAuthService authService) + { + _repository = repository; + _agentRepo = agentRepo; + _accessDeviceRepo = accessDeviceRepo; + _pairingCodeRepo = pairingCodeRepo; + _UoW = unitOfWork; + _authService = authService; + } + + public async Task> ListAsync(ClientDirectoryQuery query) + { + var (items, total) = await _repository.ListAsync(query.Search, query.Page, query.PageSize); + + var summaries = items.Select(c => new ClientSummary( + c.ClientId, + c.ClientName, + c.ClientBusinesses.FirstOrDefault()?.City, + c.PaymentTrack?.ToString(), + c.ClientBusinesses.Any(b => b.IsActive), + c.ClientBusinesses.OrderBy(b => b.createdAt).FirstOrDefault()?.createdAt ?? DateTime.MinValue + )).ToList(); + + return new PagedResult(summaries, total, query.Page, query.PageSize); + } + + public async Task GetByIdAsync(Guid id) + { + var client = await _repository.GetByIdAsync(id) + ?? throw new NotFoundException("Client not found."); + + return new + { + client.ClientId, + client.ClientName, + client.ClientPhone, + client.ClientEmail, + PaymentTrack = client.PaymentTrack?.ToString(), + Businesses = client.ClientBusinesses.Select(b => new + { + b.Bid, + b.BusinessName, + b.Address, + b.City, + b.IsActive + }) + }; + } + + public async Task ResendWelcomeEmailAsync(Guid clientId, HttpContext httpContext) + { + var client = await _repository.GetByIdAsync(clientId) + ?? throw new NotFoundException("Client not found."); + + if (string.IsNullOrWhiteSpace(client.ClientEmail)) + throw new BadRequestException("This client has no email on file to send the link to."); + + var authResponse = await _authService.ForgotPassword( + new { identifier = client.ClientEmail, useResetLink = true, welcome = true }, + httpContext); + + if (authResponse.StatusCode != 200 || !authResponse.Success) + throw new BadRequestException(authResponse.Message ?? "Couldn't resend the welcome email."); + } + + public async Task GeneratePairingCodeAsync(Guid clientId) + { + if (!await _repository.ClientExistsAsync(clientId)) + throw new NotFoundException("Client not found."); + + var pairingCode = new PairingCode + { + Id = Guid.NewGuid(), + Code = GenerateShortCode(), + FKClientId = clientId, + ExpiresAt = DateTime.UtcNow.AddMinutes(15), + IsUsed = false, + CreatedAt = DateTime.UtcNow + }; + + await _UoW.BeginAsync(); + try + { + await _pairingCodeRepo.AddAsync(pairingCode); + await _UoW.CommitAsync(); + } + catch + { + await _UoW.RollbackAsync(); + throw; + } + + return new PairingCodeResult(pairingCode.Code, pairingCode.ExpiresAt); + } + + public async Task GetDevicesAsync(Guid clientId) + { + if (!await _repository.ClientExistsAsync(clientId)) + throw new NotFoundException("Client not found."); + + var agents = await _agentRepo.GetByClientIdAsync(clientId); + var devices = await _accessDeviceRepo.GetDevicesByClientAsync(clientId); + + var now = DateTime.UtcNow; + var activeAgentIds = agents + .Where(a => a.IsActive) + .Select(a => a.AgentId) + .ToHashSet(); + + var agentDtos = agents + .Select(agent => new AdminAgentDto( + agent.AgentId, + agent.Name, + agent.IsActive, + agent.LastSeenAt, + agent.IsActive && agent.LastSeenAt.HasValue && + now - agent.LastSeenAt.Value <= HealthOnlineWindow, + agent.CreatedAt)) + .ToList(); + + var deviceDtos = devices + .Select(device => + { + var hasHealthReport = device.LastHealthReportedAt.HasValue; + var presenceTimestamp = hasHealthReport + ? device.LastHealthReportedAt + : device.LastSeenAt; + var window = hasHealthReport + ? HealthOnlineWindow + : LegacyOnlineWindow; + var isOnline = + device.IsActive && + device.FKAgentId.HasValue && + activeAgentIds.Contains(device.FKAgentId.Value) && + presenceTimestamp.HasValue && + now - presenceTimestamp.Value <= window && + (!hasHealthReport || device.IsReachable); + + return new AdminDeviceDto( + device.DeviceId, + device.DeviceName, + device.IsActive, + device.LastSeenAt, + isOnline, + isOnline ? presenceTimestamp!.Value.Add(window) : null, + device.FKAgentId.HasValue, + device.CreatedAt); + }) + .ToList(); + + return new AdminClientDevicesResult(agentDtos, deviceDtos); + } + + private static string GenerateShortCode(int length = 8) + { + const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + var bytes = RandomNumberGenerator.GetBytes(length); + var chars = new char[length]; + for (int i = 0; i < length; i++) + chars[i] = alphabet[bytes[i] % alphabet.Length]; + return new string(chars); + } + } +} diff --git a/Services/AdminSuggestionService.cs b/Services/AdminSuggestionService.cs new file mode 100644 index 0000000..f23c51d --- /dev/null +++ b/Services/AdminSuggestionService.cs @@ -0,0 +1,60 @@ +using System.Text.Json; +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using Fitness_core_BE.Models.IOs; + +namespace Fitness_core_BE.Services +{ + public class AdminSuggestionService : IAdminSuggestionService + { + private readonly IAdminSuggestionRepository _repository; + private readonly UserRoleHelper _userRoleHelper; + private readonly IUnitOfWork _uow; + public AdminSuggestionService(IAdminSuggestionRepository repository, UserRoleHelper userRoleHelper, IUnitOfWork uow) => (_repository, _userRoleHelper, _uow) = (repository, userRoleHelper, uow); + public async Task ListAsync() + { + var (workouts, meals) = await _repository.GetPublishedAsync(); + return new { workouts, meals }; + } + public async Task CreateWorkoutAsync(AdminWorkoutSuggestionRequest request, HttpContext httpContext) + { + await EnsureAdminAsync(httpContext); + if (string.IsNullOrWhiteSpace(request.Name) || request.Days.Count == 0) throw new BadRequestException("Name and at least one workout day are required."); + await _uow.BeginAsync(); + try { + var category = await GetWorkoutCategoryAsync(request.Category, request.CategoryDescription); + var plan = new WorkoutPlan { WorkoutPlanId = Guid.NewGuid(), WorkoutPlanName = request.Name.Trim(), Description = request.Description, FKWorkoutCategoryId = category.WorkoutCategoryId, IsTemplate = true, IsActive = true, CreateDate = DateTime.UtcNow }; + foreach (var day in request.Days.Where(d => !string.IsNullOrWhiteSpace(d.Day))) + { + var item = new WorkoutItem { WorkoutItemId = Guid.NewGuid(), ExerciseDay = day.Day.Trim(), Description = day.Description, Exercises = JsonSerializer.Serialize(day.Exercises), HowToDo = JsonSerializer.Serialize(new { suitableFor = day.SuitableFor, exercises = day.Exercises }), Duration = day.Duration, WormUp = day.WarmUp, Cooldown = day.Cooldown, IsActive = true, CreateDate = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; + var order = 1; + foreach (var exercise in day.Exercises.Where(e => !string.IsNullOrWhiteSpace(e.Name))) + { + item.WorkoutExercises.Add(new WorkoutExercise { WorkoutExerciseId = Guid.NewGuid(), Name = exercise.Name.Trim(), MuscleGroup = exercise.Position, Sets = exercise.Sets, Reps = exercise.Reps, Description = exercise.Description, BreathIn = exercise.BreathIn, BreathOut = exercise.BreathOut, VideoUrl = exercise.VideoUrl, SortOrder = order++, IsActive = true, CreateDate = DateTime.UtcNow }); + } + plan.WorkoutItems.Add(item); + } + await _repository.AddWorkoutAsync(plan); await _uow.CommitAsync(); return new { plan.WorkoutPlanId, numberOfDays = plan.WorkoutItems.Count }; + } catch { await _uow.RollbackAsync(); throw; } + } + public async Task CreateMealAsync(AdminMealSuggestionRequest request, HttpContext httpContext) + { + await EnsureAdminAsync(httpContext); + if (string.IsNullOrWhiteSpace(request.Name) || request.Meals.Count == 0) throw new BadRequestException("Name and at least one meal are required."); + await _uow.BeginAsync(); + try { + var category = await GetMealCategoryAsync(request.Category, request.CategoryDescription); + var plan = new MealPlan { MealPlanId = Guid.NewGuid(), Name = request.Name.Trim(), Description = request.Description, FKMealCategoryId = category.MealCategoryId, IsTemplate = true, IsActive = true, CreateDate = DateTime.UtcNow }; + foreach (var meal in request.Meals.Where(m => !string.IsNullOrWhiteSpace(m.Name))) plan.MealItems.Add(new Models.Meal { MealId = Guid.NewGuid(), Name = meal.Name.Trim(), Description = meal.Description, Calories = meal.Calories, Protein = meal.Protein, Carbohydrates = meal.Carbohydrates, Fats = meal.Fats, ServingSize = meal.ServingSize, MealItems = JsonSerializer.Serialize(meal.Foods), PreparationNotes = meal.PreparationNotes, IsActive = true, CreateDate = DateTime.UtcNow }); + await _repository.AddMealAsync(plan); await _uow.CommitAsync(); return new { plan.MealPlanId, mealCount = plan.MealItems.Count }; + } catch { await _uow.RollbackAsync(); throw; } + } + private async Task EnsureAdminAsync(HttpContext context) { var id = DataExtractor.GetUserIdFromContext(context); if ((await _userRoleHelper.GetAdminId(id).ConfigureAwait(false)) is not Guid) throw new ForbiddenException("Only admins can manage platform suggestions."); } + private async Task GetWorkoutCategoryAsync(string name, string? description) { name = string.IsNullOrWhiteSpace(name) ? "General Fitness" : name.Trim(); var category = await _repository.FindWorkoutCategoryAsync(name); if (category != null) return category; category = new WorkoutCategory { WorkoutCategoryId = Guid.NewGuid(), WorkoutCategoryName = name, Description = description, IsActive = true, CreateDate = DateTime.UtcNow }; await _repository.AddWorkoutCategoryAsync(category); return category; } + private async Task GetMealCategoryAsync(string name, string? description) { name = string.IsNullOrWhiteSpace(name) ? "Balanced Nutrition" : name.Trim(); var category = await _repository.FindMealCategoryAsync(name); if (category != null) return category; category = new MealCategory { MealCategoryId = Guid.NewGuid(), Name = name, Description = description, IsActive = true, CreateDate = DateTime.UtcNow }; await _repository.AddMealCategoryAsync(category); return category; } + } +} diff --git a/Services/AgentService.cs b/Services/AgentService.cs new file mode 100644 index 0000000..50a837d --- /dev/null +++ b/Services/AgentService.cs @@ -0,0 +1,731 @@ +using Fitness_core_BE.DTOs; +using Fitness_core_BE.DTOs.Agent; +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Hubs; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace Fitness_core_BE.Services +{ + public class AgentService : IAgentService + { + private static readonly TimeSpan DevicePresenceWindow = TimeSpan.FromSeconds(90); + + private readonly IUnitOfWork _UoW; + private readonly IAgentRepo _agentRepo; + private readonly IPairingCodeRepo _pairingCodeRepo; + private readonly IDeviceCommandRepo _deviceCommandRepo; + private readonly IAccessDeviceRepo _accessDeviceRepo; + private readonly IAccessDeviceService _accessDeviceService; + private readonly ILogger _logger; + private readonly IHubContext _accessHub; + + public AgentService( + IUnitOfWork unitOfWork, + IAgentRepo agentRepo, + IPairingCodeRepo pairingCodeRepo, + IDeviceCommandRepo deviceCommandRepo, + IAccessDeviceRepo accessDeviceRepo, + IAccessDeviceService accessDeviceService, + ILogger logger, + IHubContext accessHub) + { + _UoW = unitOfWork; + _agentRepo = agentRepo; + _pairingCodeRepo = pairingCodeRepo; + _deviceCommandRepo = deviceCommandRepo; + _accessDeviceRepo = accessDeviceRepo; + _accessDeviceService = accessDeviceService; + _logger = logger; + _accessHub = accessHub; + } + + public async Task EnrollAsync(EnrollRequest request) + { + if (request == null || string.IsNullOrWhiteSpace(request.Code)) + throw new BadRequestException("Pairing code is required."); + if (string.IsNullOrWhiteSpace(request.AgentName)) + throw new BadRequestException("Agent name is required."); + + var pairingCode = await _pairingCodeRepo.GetActiveByCodeAsync(request.Code); + if (pairingCode == null) + throw new BadRequestException("Pairing code is invalid, used, or expired."); + + var apiKey = ApiKeyHelper.GenerateApiKey(); + var agent = new Agent + { + AgentId = Guid.NewGuid(), + FKClientId = pairingCode.FKClientId, + Name = request.AgentName.Trim(), + ApiKeyHash = ApiKeyHelper.HashApiKey(apiKey), + IsActive = true, + CreatedAt = DateTime.UtcNow + }; + + await _UoW.BeginAsync(); + try + { + await _agentRepo.AddAsync(agent); + await _pairingCodeRepo.MarkUsedAsync(pairingCode); + await _UoW.CommitAsync(); + } + catch + { + await _UoW.RollbackAsync(); + throw; + } + return new { agentId = agent.AgentId, apiKey }; + } + + public async Task RegisterDeviceAsync( + Guid clientId, Guid agentId, RegisterDeviceRequest request) + { + if (request == null || string.IsNullOrWhiteSpace(request.DeviceName)) + throw new BadRequestException("Device name is required."); + + var hardwareId = string.IsNullOrWhiteSpace(request.HardwareId) + ? null + : request.HardwareId.Trim(); + AccessDevice? device = null; + + if (request.DeviceId.HasValue && request.DeviceId.Value != Guid.Empty) + { + device = await _accessDeviceRepo.GetDeviceByIdAsync(request.DeviceId.Value); + if (device != null && device.FKClientId != clientId) + throw new ForbiddenException("Device belongs to another client."); + } + + if (device == null && hardwareId != null) + { + device = await _accessDeviceRepo + .GetDeviceByHardwareIdAsync(clientId, hardwareId); + } + + await _UoW.BeginAsync(); + try + { + if (device == null) + { + device = new AccessDevice + { + DeviceId = Guid.NewGuid(), + FKClientId = clientId, + CreatedAt = DateTime.UtcNow + }; + await _accessDeviceRepo.AddDeviceAsync(device); + } + + device.DeviceName = request.DeviceName.Trim(); + device.HardwareId = hardwareId ?? device.HardwareId; + device.FKAgentId = agentId; + device.IsActive = true; + device.LastSeenAt = DateTime.UtcNow; + + // The row must exist before bindings/commands reference it. + await _UoW.SaveChangesAsync(); + await _accessDeviceService.ProvisionProfilesToDeviceAsync(device); + await _UoW.CommitAsync(); + } + catch + { + await _UoW.RollbackAsync(); + throw; + } + + return new { deviceId = device.DeviceId }; + } + + public async Task ReconcileDeviceInventoryAsync( + Guid clientId, Guid agentId, DeviceInventoryRequest request) + { + if (request == null || request.DeviceId == Guid.Empty) + throw new BadRequestException("Device id is required."); + + var device = await _accessDeviceRepo + .GetActiveDeviceForClientAsync(clientId, request.DeviceId); + if (device == null) + throw new ForbiddenException("Device does not belong to this client."); + if (device.FKAgentId != agentId) + throw new ForbiddenException("Device is assigned to another agent."); + + var now = DateTime.UtcNow; + var discovered = 0; + var updated = 0; + var reportedEmployeeNos = new HashSet(StringComparer.Ordinal); + + await _UoW.BeginAsync(); + try + { + device.LastSeenAt = now; + foreach (var person in request.People ?? new List()) + { + var employeeNo = person.EmployeeNo?.Trim(); + if (string.IsNullOrWhiteSpace(employeeNo)) + continue; + reportedEmployeeNos.Add(employeeNo); + + var binding = await _accessDeviceRepo + .GetBindingByEmployeeNoAsync(request.DeviceId, employeeNo); + if (binding != null) + { + binding.DeviceReportedName = person.Name; + binding.FingerprintCount = Math.Max(0, person.FingerprintCount); + binding.ActualAccessState = + IsBlocked(person.UserType) ? "Disabled" : "Enabled"; + var matchesDesired = + binding.ActualAccessState == binding.AccessProfile.DesiredAccessState; + binding.ProvisionStatus = matchesDesired ? "Synced" : "Pending"; + binding.LastError = matchesDesired + ? null + : "Terminal access state differs from the platform; repair queued."; + binding.LastSyncedAt = now; + binding.UpdatedAt = now; + if (!matchesDesired) + await QueueInventoryRepairAsync(binding, "SetAccessState", now); + updated++; + continue; + } + + var unlinked = await _accessDeviceRepo + .GetUnlinkedEnrollmentByEmployeeNoAsync( + request.DeviceId, employeeNo); + + if (unlinked == null) + { + unlinked = new UnlinkedDeviceEnrollment + { + Id = Guid.NewGuid(), + FKDeviceId = request.DeviceId, + DeviceEmployeeNo = employeeNo, + DeviceUserId = int.TryParse(employeeNo, out var numericId) ? numericId : 0, + DiscoveredAt = now + }; + await _accessDeviceRepo.AddUnlinkedEnrollmentAsync(unlinked); + discovered++; + } + + unlinked.DeviceReportedName = person.Name; + unlinked.DeviceUserType = person.UserType; + unlinked.FingerprintCount = Math.Max(0, person.FingerprintCount); + unlinked.LastSeenAt = now; + } + + var missingBindings = await _accessDeviceRepo + .GetBindingsForDeviceAsync(request.DeviceId); + foreach (var binding in missingBindings.Where( + b => !reportedEmployeeNos.Contains(b.DeviceEmployeeNo))) + { + binding.ProvisionStatus = "Pending"; + binding.ActualAccessState = "Unknown"; + binding.FingerprintCount = 0; + binding.LastError = "Terminal person is missing; recreation queued."; + binding.LastSyncedAt = now; + binding.UpdatedAt = now; + await QueueInventoryRepairAsync(binding, "UpsertPerson", now); + } + + await _UoW.CommitAsync(); + } + catch + { + await _UoW.RollbackAsync(); + throw; + } + + var total = request.People?.Count ?? 0; + await PublishAccessEventAsync(clientId, "DeviceInventoryUpdated", new + { + deviceId = request.DeviceId, + discovered, + updated, + total, + occurredAt = now + }); + return new { discovered, updated, total }; + } + + public async Task IngestAttendanceAsync( + Guid clientId, Guid agentId, AgentAttendanceRequest request) + { + if (request == null) + throw new BadRequestException("Request body is required."); + var device = await _accessDeviceRepo + .GetActiveDeviceForClientAsync(clientId, request.DeviceId); + if (device == null) + throw new ForbiddenException("Device does not belong to this client."); + if (device.FKAgentId != agentId) + throw new ForbiddenException("Device is assigned to another agent."); + + var eventCount = request.Events?.Count ?? 0; + var attendanceList = new List(); + var unknown = 0; + + foreach (var ev in request.Events ?? new List()) + { + var employeeNo = ev.EmployeeNoString?.Trim(); + if (string.IsNullOrWhiteSpace(employeeNo)) + continue; + + var binding = await _accessDeviceRepo + .GetBindingByEmployeeNoAsync(request.DeviceId, employeeNo); + if (binding == null) + { + await UpsertUnknownFromAttendanceAsync(request.DeviceId, employeeNo); + unknown++; + continue; + } + + Guid? customerId = null; + if (string.Equals( + binding.AccessProfile.PersonType, + "Customer", + StringComparison.OrdinalIgnoreCase)) + { + customerId = await _accessDeviceRepo + .GetCustomerIdForUserAtClientAsync( + binding.AccessProfile.FKUserId, clientId); + } + + attendanceList.Add(new AttendanceDTO + { + DeviceId = request.DeviceId, + CustomerId = customerId, + UserId = binding.AccessProfile.FKUserId, + AccessProfileId = binding.FKAccessProfileId, + DeviceUserId = int.TryParse(employeeNo, out var numericId) ? numericId : 0, + DeviceEmployeeNo = employeeNo, + EventTime = ev.Time, + EventType = string.IsNullOrWhiteSpace(ev.AttendanceStatus) ? "Unknown" : ev.AttendanceStatus + }); + } + + device.LastSeenAt = DateTime.UtcNow; + if (unknown > 0 || eventCount > 0) + await _UoW.SaveChangesAsync(); + + var inserted = await _accessDeviceService.SaveAttendanceRecords(clientId, attendanceList); + if (inserted > 0 || unknown > 0) + { + await PublishAccessEventAsync(clientId, "AttendanceUpdated", new + { + deviceId = request.DeviceId, + inserted, + unknown, + received = eventCount, + occurredAt = DateTime.UtcNow + }); + } + if (unknown > 0) + { + await PublishAccessEventAsync(clientId, "DeviceInventoryUpdated", new + { + deviceId = request.DeviceId, + discovered = unknown, + updated = 0, + total = unknown, + occurredAt = DateTime.UtcNow + }); + } + _logger.LogInformation( + "Attendance ingest: client {ClientId}, device {DeviceId}, {Received} received, " + + "{Unknown} unknown, {Inserted} inserted.", + clientId, request.DeviceId, eventCount, unknown, inserted); + return new { inserted, unknown }; + } + + public async Task GetPendingCommandsAsync(Guid clientId, Guid agentId) + { + var now = DateTime.UtcNow; + + // Close commands whose final lease expired without a successful report. + var exhausted = await _deviceCommandRepo + .GetExhaustedLeasesAsync(clientId, now); + foreach (var command in exhausted) + { + command.Status = "Failed"; + command.ProcessedAt = now; + command.ResultJson = command.ResultJson ?? "{\"detail\":\"Command lease expired.\"}"; + if (command.AccessDeviceBinding != null) + { + command.AccessDeviceBinding.ProvisionStatus = "Failed"; + command.AccessDeviceBinding.LastError = "Command lease expired."; + command.AccessDeviceBinding.UpdatedAt = now; + } + } + + var commands = await _deviceCommandRepo + .GetLeaseableCommandsAsync(clientId, agentId, now, 100); + + foreach (var command in commands) + { + command.Status = "InProgress"; + command.AttemptCount++; + command.LeaseExpiresAt = now.AddSeconds(90); + } + await _UoW.SaveChangesAsync(); + + return commands.Select(c => new + { + id = c.Id, + commandType = c.CommandType, + payloadJson = c.PayloadJson, + fkDeviceId = c.FKDeviceId, + attempt = c.AttemptCount, + createdAt = c.CreatedAt + }).ToList(); + } + + public async Task SubmitCommandResultAsync( + Guid clientId, Guid agentId, Guid commandId, CommandResultRequest request) + { + if (request == null) + throw new BadRequestException("Request body is required."); + + var command = await _deviceCommandRepo.GetForAgentResultAsync(commandId) + ?? throw new NotFoundException($"Command {commandId} not found."); + if (command.FKClientId != clientId) + throw new ForbiddenException("Command does not belong to this client."); + if (command.AccessDevice?.FKAgentId != agentId) + throw new ForbiddenException("Command belongs to another agent."); + + var now = DateTime.UtcNow; + var binding = command.AccessDeviceBinding; + var versionIsCurrent = binding == null || + command.DesiredVersion == binding.AccessProfile.DesiredVersion; + + // A face photo carries its image in the payload and reports enrolment state on + // the profile, not the binding's access state -- so it takes its own path. + var isFacePhoto = command.CommandType == "UpsertFacePhoto"; + + if (isFacePhoto) + { + HandleFacePhotoResult(command, binding, request, versionIsCurrent, now); + } + else if (request.Success) + { + command.Status = "Completed"; + command.ProcessedAt = now; + command.LeaseExpiresAt = null; + if (binding != null && versionIsCurrent) + { + binding.ProvisionStatus = "Synced"; + binding.ActualAccessState = ReadEnabled(command.PayloadJson) ? "Enabled" : "Disabled"; + binding.LastError = null; + binding.LastSyncedAt = now; + binding.UpdatedAt = now; + } + } + else if (command.AttemptCount < command.MaxAttempts && versionIsCurrent) + { + command.Status = "Pending"; + command.LeaseExpiresAt = null; + command.NextAttemptAt = now.AddSeconds(Math.Min(Math.Pow(2, command.AttemptCount) * 5, 300)); + if (binding != null) + { + binding.ProvisionStatus = "Pending"; + binding.LastError = ExtractResultDetail(request.ResultJson); + binding.UpdatedAt = now; + } + } + else + { + command.Status = "Failed"; + command.ProcessedAt = now; + command.LeaseExpiresAt = null; + if (binding != null && versionIsCurrent) + { + binding.ProvisionStatus = "Failed"; + binding.LastError = ExtractResultDetail(request.ResultJson); + binding.UpdatedAt = now; + } + } + + command.ResultJson = request.ResultJson; + await _UoW.SaveChangesAsync(); + + if (isFacePhoto) + { + var profile = binding?.AccessProfile; + await PublishAccessEventAsync(clientId, "FacePhotoUpdated", new + { + commandId = command.Id, + bindingId = command.FKAccessDeviceBindingId, + accessProfileId = profile?.AccessProfileId, + facePhotoStatus = profile?.FacePhotoStatus, + hasFacePhoto = profile?.HasFacePhoto, + error = profile?.FacePhotoError, + occurredAt = now + }); + } + else + { + await PublishAccessEventAsync(clientId, "AccessStateUpdated", new + { + commandId = command.Id, + bindingId = command.FKAccessDeviceBindingId, + status = command.Status, + success = request.Success, + occurredAt = now + }); + } + + return new { command.Id, command.Status, command.AttemptCount, command.MaxAttempts }; + } + + /// + /// Applies a face-photo command result to the access profile and, on any terminal + /// state, drops the image from the command payload. The photo is only ever held + /// while it is in flight to a terminal -- never stored. + /// + private void HandleFacePhotoResult( + DeviceCommand command, + AccessDeviceBinding? binding, + CommandResultRequest request, + bool versionIsCurrent, + DateTime now) + { + var profile = binding?.AccessProfile; + + if (request.Success) + { + command.Status = "Completed"; + command.ProcessedAt = now; + command.LeaseExpiresAt = null; + command.PayloadJson = string.Empty; // purge the image + + if (profile != null && versionIsCurrent) + { + profile.HasFacePhoto = true; + profile.FacePhotoStatus = "Synced"; + profile.FacePhotoError = null; + // The agent enrols the face under the person's employeeNo, so that is the + // FPID. It is read from the result when the agent reports one, and falls + // back to the binding otherwise -- either way this must be populated, as + // it is the only handle a later replace/delete has. + profile.FacePhotoFPID = + ExtractFacePhotoFpid(request.ResultJson) ?? binding?.DeviceEmployeeNo; + profile.FacePhotoUpdatedAt = now; + profile.UpdatedAt = now; + } + return; + } + + // A rejected face (not frontal, eyes closed, poor lighting) will be rejected the + // same way on every retry, so the payload is kept only while retries remain. + if (command.AttemptCount < command.MaxAttempts && versionIsCurrent) + { + command.Status = "Pending"; + command.LeaseExpiresAt = null; + command.NextAttemptAt = now.AddSeconds( + Math.Min(Math.Pow(2, command.AttemptCount) * 5, 300)); + + if (profile != null) + { + profile.FacePhotoStatus = "Pending"; + profile.FacePhotoError = ExtractResultDetail(request.ResultJson); + profile.FacePhotoUpdatedAt = now; + } + return; + } + + command.Status = "Failed"; + command.ProcessedAt = now; + command.LeaseExpiresAt = null; + command.PayloadJson = string.Empty; // purge the image + + if (profile != null && versionIsCurrent) + { + profile.FacePhotoStatus = "Failed"; + profile.FacePhotoError = ExtractResultDetail(request.ResultJson); + profile.FacePhotoUpdatedAt = now; + profile.UpdatedAt = now; + } + } + + /// Pulls the Hikvision face-record id out of the agent's result JSON. + private static string? ExtractFacePhotoFpid(string? resultJson) + { + if (string.IsNullOrWhiteSpace(resultJson)) + return null; + try + { + using var doc = JsonDocument.Parse(resultJson); + foreach (var name in new[] { "fpid", "FPID", "faceId", "faceURL" }) + { + if (doc.RootElement.TryGetProperty(name, out var value) && + value.ValueKind == JsonValueKind.String) + { + var id = value.GetString(); + if (!string.IsNullOrWhiteSpace(id)) + return id.Length <= 64 ? id : id[..64]; + } + } + } + catch { } + return null; + } + + public async Task HeartbeatAsync( + Guid clientId, Guid agentId, AgentHeartbeatRequest request) + { + var serverTime = DateTime.UtcNow; + await _agentRepo.UpdateLastSeenAsync(agentId, serverTime); + + var reports = (request.Devices ?? new List()) + .Where(report => report.DeviceId != Guid.Empty) + .GroupBy(report => report.DeviceId) + .Select(group => group.Last()) + .ToList(); + var reportedIds = reports.Select(report => report.DeviceId).ToList(); + var devices = reportedIds.Count == 0 + ? new List() + : await _accessDeviceRepo.GetAgentDevicesAsync( + clientId, agentId, reportedIds); + + foreach (var device in devices) + { + var report = reports.First(item => item.DeviceId == device.DeviceId); + device.IsReachable = report.IsOnline; + device.LastHealthReportedAt = serverTime; + if (report.IsOnline) + device.LastSeenAt = serverTime; + } + await _UoW.SaveChangesAsync(); + + var presence = devices.Select(device => new + { + deviceId = device.DeviceId, + isOnline = device.IsActive && device.IsReachable, + lastSeenAt = device.LastSeenAt, + presenceExpiresAt = device.IsActive && device.IsReachable + ? serverTime.Add(DevicePresenceWindow) + : (DateTime?)null + }).ToList(); + if (presence.Count > 0) + { + await PublishAccessEventAsync(clientId, "DevicePresenceUpdated", new + { + agentId, + devices = presence, + occurredAt = serverTime + }); + } + + return new { serverTime, devices = presence }; + } + + private async Task UpsertUnknownFromAttendanceAsync(Guid deviceId, string employeeNo) + { + var existing = await _accessDeviceRepo + .GetUnlinkedEnrollmentByEmployeeNoAsync(deviceId, employeeNo); + if (existing != null) + { + existing.LastSeenAt = DateTime.UtcNow; + return; + } + + await _accessDeviceRepo.AddUnlinkedEnrollmentAsync(new UnlinkedDeviceEnrollment + { + Id = Guid.NewGuid(), + FKDeviceId = deviceId, + DeviceEmployeeNo = employeeNo, + DeviceUserId = int.TryParse(employeeNo, out var numericId) ? numericId : 0, + DiscoveredAt = DateTime.UtcNow, + LastSeenAt = DateTime.UtcNow + }); + } + + private async Task QueueInventoryRepairAsync( + AccessDeviceBinding binding, string commandType, DateTime now) + { + var profile = binding.AccessProfile; + var alreadyQueued = await _deviceCommandRepo.IsQueuedAsync( + binding.AccessDeviceBindingId, + commandType, + profile.DesiredVersion); + if (alreadyQueued) + return; + + var payloadJson = JsonSerializer.Serialize(new + { + bindingId = binding.AccessDeviceBindingId, + accessProfileId = profile.AccessProfileId, + employeeNo = binding.DeviceEmployeeNo, + name = profile.DisplayName, + enabled = profile.DesiredAccessState == "Enabled", + desiredVersion = profile.DesiredVersion + }); + await _deviceCommandRepo.AddAsync(new DeviceCommand + { + Id = Guid.NewGuid(), + FKClientId = profile.FKClientId, + FKDeviceId = binding.FKDeviceId, + FKAccessDeviceBindingId = binding.AccessDeviceBindingId, + CommandType = commandType, + PayloadJson = payloadJson, + DesiredVersion = profile.DesiredVersion, + Status = "Pending", + MaxAttempts = 5, + NextAttemptAt = now, + CreatedAt = now + }); + } + + private static bool IsBlocked(string? userType) => + string.Equals(userType, "blackList", StringComparison.OrdinalIgnoreCase); + + private static bool ReadEnabled(string payloadJson) + { + try + { + using var doc = JsonDocument.Parse(payloadJson); + return !doc.RootElement.TryGetProperty("enabled", out var enabled) || enabled.GetBoolean(); + } + catch + { + return true; + } + } + + private static string ExtractResultDetail(string? resultJson) + { + if (string.IsNullOrWhiteSpace(resultJson)) + return "Device command failed."; + try + { + using var doc = JsonDocument.Parse(resultJson); + if (doc.RootElement.TryGetProperty("detail", out var detail)) + return detail.GetString() ?? resultJson; + } + catch { } + return resultJson.Length <= 1000 ? resultJson : resultJson[..1000]; + } + + private async Task PublishAccessEventAsync( + Guid clientId, string eventName, object payload) + { + try + { + await _accessHub.Clients + .Group(AccessHub.ClientGroup(clientId)) + .SendAsync(eventName, payload); + } + catch (Exception ex) + { + // Device ingestion is authoritative; a transient socket/backplane + // failure must never make the agent retry a committed database write. + _logger.LogWarning( + ex, + "Could not publish {EventName} for client {ClientId}.", + eventName, + clientId); + } + } + } +} diff --git a/Services/AttendanceService.cs b/Services/AttendanceService.cs new file mode 100644 index 0000000..4cf7e33 --- /dev/null +++ b/Services/AttendanceService.cs @@ -0,0 +1,73 @@ +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; + +namespace Fitness_core_BE.Services +{ + public class AttendanceService : IAttendanceService + { + private readonly IAttendanceRepository _attendanceRepo; + private readonly UserRoleHelper _userRoleHelper; + private readonly IAccessDeviceService _accessDeviceService; + + public AttendanceService( + IAttendanceRepository attendanceRepo, + UserRoleHelper userRoleHelper, + IAccessDeviceService accessDeviceService) + { + _attendanceRepo = attendanceRepo; + _userRoleHelper = userRoleHelper; + _accessDeviceService = accessDeviceService; + } + + public async Task GetAllAttendanceRecords(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + //await _accessDeviceService.GetAttendance(clientId); + return await _attendanceRepo.GetLatestPerPersonAsync(clientId); + } + + public async Task GetAttendanceCounts(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + //await _accessDeviceService.GetAttendance(clientId); + var fromDate = DataExtractor.GetOptionalDateTime(data, "fromDate"); + var toDate = DataExtractor.GetOptionalDateTime(data, "toDate"); + + var now = DateTime.UtcNow; + DateTime resolvedFrom; + DateTime resolvedTo; + + if (fromDate == null && toDate == null) + { + resolvedFrom = new DateTime(now.Year, now.Month, 1); + resolvedTo = now.Date.AddDays(1).AddTicks(-1); + } + else if (fromDate != null && toDate == null) + { + resolvedFrom = fromDate.Value; + resolvedTo = now.Date.AddDays(1).AddTicks(-1); + } + else + { + resolvedFrom = fromDate!.Value; + resolvedTo = toDate!.Value.Date.AddDays(1).AddTicks(-1); + } + + return await _attendanceRepo.GetAttendanceCountsAsync(clientId, resolvedFrom, resolvedTo); + } + + public async Task GetTodayAttendance(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + //await _accessDeviceService.GetAttendance(clientId); + return await _attendanceRepo.GetTodayAttendanceAsync(clientId); + } + } +} diff --git a/Services/AuthService.cs b/Services/AuthService.cs new file mode 100644 index 0000000..8d4521f --- /dev/null +++ b/Services/AuthService.cs @@ -0,0 +1,689 @@ +using Fitness_core_BE.DTOs.Auth; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using System.Net.Http; +using System.Text; +using System.Text.Json; + +namespace Fitness_core_BE.Services.Auth +{ + public class AuthService(IHttpClientFactory httpClientFactory, ICustomerRepository customerRepository, IUnitOfWork unitOfWork, IConfiguration configuration) : IAuthService + { + + private readonly HttpClient _httpClient = httpClientFactory.CreateClient(); + private readonly ICustomerRepository _cusRepo = customerRepository; + private readonly IUnitOfWork _UoW = unitOfWork; + private readonly string _baseUrl = configuration["AuthService:BaseUrl"] ?? "http://127.0.0.1:5602"; + + private string AuthAltTargetUrl => _baseUrl + "/api/alt"; + private string AuthUserTargetUrl => _baseUrl + "/api/user"; + private string AuthRecoveryTargetUrl => _baseUrl + "/api/recovery"; + + + + public async Task SendOtp(object payload, HttpContext httpContext) + { + var targetUrl = AuthAltTargetUrl; + var request = new + { + functionName = "sendOtp", + payload, + reference = "send-otp" + }; + + var json = JsonSerializer.Serialize(request); + + var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + try + { + var response = await _httpClient.SendAsync(forwardRequest); + var responseContent = await response.Content.ReadAsStringAsync(); + + var authResponse = JsonSerializer.Deserialize(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + return authResponse ?? new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null }; + } + catch (Exception ex) + { + return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null }; + } + } + + public async Task LoginUser(object payload, HttpContext httpContext) + { + // Web clients explicitly opt in to a persistent 30-day cookie. Without + // this, closing the browser ends the session even though Auth retains the + // server-side refresh session for security/audit purposes. + var loginData = DeserializePayload.Deserialize(payload); + var rememberMe = loginData?.TryGetValue("rememberMe", out var rememberValue) == true + && rememberValue.ValueKind is System.Text.Json.JsonValueKind.True; + var targetUrl = AuthUserTargetUrl; + + var request = new + { + functionName = "loginUser", + payload, + reference = "LoginUser" + }; + + var json = JsonSerializer.Serialize(request); + + var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + try + { + var response = await _httpClient.SendAsync(forwardRequest); + var responseContent = await response.Content.ReadAsStringAsync(); + var authResponse = JsonSerializer.Deserialize( responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (authResponse == null) + return new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null }; + + if (authResponse.Success && authResponse.Data is JsonElement dataElement) + { + SetAuthCookiesFromData(httpContext, dataElement, rememberMe); + } + + return authResponse; + } + catch (Exception ex) + { + throw new Exception($"Auth service error: {ex.Message}"); + } + } + + /// Sets the access_token (and refresh_token if present) cookies from an auth-service data payload. + private void SetAuthCookiesFromData(HttpContext httpContext, JsonElement dataElement, bool persistent = true) + { + string? accessToken = null; + if (dataElement.TryGetProperty("accessToken", out var tokenProp) || + dataElement.TryGetProperty("AccessToken", out tokenProp)) + accessToken = tokenProp.GetString(); + if (!string.IsNullOrEmpty(accessToken)) + SetAccessTokenCookieInternal(httpContext, accessToken, persistent); + + string? refreshToken = null; + if (dataElement.TryGetProperty("refreshToken", out var refreshProp) || + dataElement.TryGetProperty("RefreshToken", out refreshProp)) + refreshToken = refreshProp.GetString(); + if (!string.IsNullOrEmpty(refreshToken)) + SetRefreshTokenCookie(httpContext, refreshToken, persistent); + } + + public void SetAccessTokenCookie(HttpContext httpContext, string token) + => SetAccessTokenCookieInternal(httpContext, token, persistent: true); + + private void SetAccessTokenCookieInternal(HttpContext httpContext, string token, bool persistent) + { + var requestHost = httpContext.Request.Host.Host; + var forwardedProto = httpContext.Request.Headers["X-Forwarded-Proto"].FirstOrDefault(); + var isHttpsRequest = + httpContext.Request.IsHttps || + string.Equals(forwardedProto, "https", StringComparison.OrdinalIgnoreCase); + + httpContext.Response.Cookies.Append("access_token", token, new CookieOptions + { + HttpOnly = true, + Secure = isHttpsRequest, + SameSite = isHttpsRequest ? SameSiteMode.None : SameSiteMode.Lax, + Expires = persistent ? DateTimeOffset.UtcNow.AddDays(30) : null, + Path = "/", + Domain = IsLocalHost(requestHost) ? null : "hexafit.hexdive.com" + }); + } + + public void SetRefreshTokenCookie(HttpContext httpContext, string refreshToken, bool persistent = true) + { + var requestHost = httpContext.Request.Host.Host; + var forwardedProto = httpContext.Request.Headers["X-Forwarded-Proto"].FirstOrDefault(); + var isHttpsRequest = + httpContext.Request.IsHttps || + string.Equals(forwardedProto, "https", StringComparison.OrdinalIgnoreCase); + + httpContext.Response.Cookies.Append("refresh_token", refreshToken, new CookieOptions + { + HttpOnly = true, + Secure = isHttpsRequest, + SameSite = isHttpsRequest ? SameSiteMode.None : SameSiteMode.Lax, + Expires = persistent ? DateTimeOffset.UtcNow.AddDays(30) : null, + Path = "/", + Domain = IsLocalHost(requestHost) ? null : "hexafit.hexdive.com" + }); + } + + public void ClearAuthCookies(HttpContext httpContext) + { + var requestHost = httpContext.Request.Host.Host; + var domain = IsLocalHost(requestHost) ? null : "hexafit.hexdive.com"; + var options = new CookieOptions { Path = "/", Domain = domain }; + httpContext.Response.Cookies.Delete("access_token", options); + httpContext.Response.Cookies.Delete("refresh_token", options); + } + + public async Task RefreshToken(object payload, HttpContext httpContext) + { + var targetUrl = AuthUserTargetUrl; + + // The refresh token lives in an httpOnly cookie so the browser can trigger a + // silent refresh without ever holding it in JS. Fall back to the request payload + // (e.g. mobile clients that pass it explicitly). + string? refreshToken = null; + try + { + var data = DeserializePayload.Deserialize(payload); + if (data.TryGetValue("refreshToken", out var rt) && rt.ValueKind == JsonValueKind.String) + refreshToken = rt.GetString(); + } + catch { /* payload may be empty on cookie-driven refresh */ } + + if (string.IsNullOrEmpty(refreshToken)) + refreshToken = httpContext.Request.Cookies["refresh_token"]; + + if (string.IsNullOrEmpty(refreshToken)) + { + ClearAuthCookies(httpContext); + return new AuthResponse { StatusCode = 401, Success = false, Message = "Missing refresh token", Data = null }; + } + + var deviceName = httpContext.Request.Headers["User-Agent"].ToString(); + var request = new + { + functionName = "refreshToken", + payload = new { refreshToken, deviceName }, + reference = "RefreshToken" + }; + + var json = JsonSerializer.Serialize(request); + + var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + if (!string.IsNullOrWhiteSpace(deviceName)) + forwardRequest.Headers.TryAddWithoutValidation("User-Agent", deviceName); + + var forwardedFor = httpContext.Connection.RemoteIpAddress?.ToString(); + if (!string.IsNullOrWhiteSpace(forwardedFor)) + forwardRequest.Headers.TryAddWithoutValidation("X-Forwarded-For", forwardedFor); + + try + { + var response = await _httpClient.SendAsync(forwardRequest); + var responseContent = await response.Content.ReadAsStringAsync(); + var authResponse = JsonSerializer.Deserialize(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (authResponse == null) + return new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null }; + + if (authResponse.Success && authResponse.Data is JsonElement dataElement) + { + // Core-Hex-Auth rotates the refresh token; persist both new cookies. + SetAuthCookiesFromData(httpContext, dataElement); + return authResponse; + } + + // Refresh token invalid/expired — clear cookies and report 401 so the + // frontend redirects to login rather than surfacing a 500. + ClearAuthCookies(httpContext); + return new AuthResponse { StatusCode = 401, Success = false, Message = "Session expired. Please log in again.", Data = null }; + } + catch (Exception ex) + { + return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null }; + } + } + + + + //customer ta only use? why + public async Task VerifyOTPForCus(object payload, HttpContext httpContext) + { + + var data = DeserializePayload.Deserialize(payload); + + + + var targetUrl = AuthRecoveryTargetUrl; + var request = new + { + functionName = "verifyOTP", + payload, + reference = "verify-otp" + }; + + var json = JsonSerializer.Serialize(request); + + var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + try + { + var response = await _httpClient.SendAsync(forwardRequest); + var responseContent = await response.Content.ReadAsStringAsync(); + + var authResponse = JsonSerializer.Deserialize(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (authResponse == null) + throw new Exception("Empty response from auth service"); + + string identifireType = DataExtractor.GetRequiredString(data, "identifireType"); + var identifier = DataExtractor.GetRequiredString(data, "identifier"); + + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + + await _UoW.BeginAsync(); + try + { + bool result = await _cusRepo.VerifyCustomer(userId, identifireType, identifier); + if (!result) + throw new Exception("verfifying faled"); + await _UoW.CommitAsync(); + } + catch(Exception ex) + { + await _UoW.RollbackAsync(); + throw new Exception($"{ex.Message}"); + } + + return authResponse; + } + catch (Exception ex) + { + return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null }; + } + } + + + public async Task VerifyOtpForLogin(object payload, HttpContext httpContext) + { + var targetUrl = AuthUserTargetUrl; + var request = new + { + functionName = "VerifyOtpForLogin", + payload, + reference = "verify-otp-for-login" + }; + + var json = JsonSerializer.Serialize(request); + + var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + try + { + var response = await _httpClient.SendAsync(forwardRequest); + var responseContent = await response.Content.ReadAsStringAsync(); + + var authResponse = JsonSerializer.Deserialize(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + return authResponse ?? new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null }; + } + catch (Exception ex) + { + return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null }; + } + } + + + + public async Task IsAvailable(object payload, HttpContext httpContext) + { + var targetUrl = AuthAltTargetUrl; + var request = new + { + functionName = "IsAvailable", + payload, + reference = "is-available" + }; + + var json = JsonSerializer.Serialize(request); + + var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + try + { + var response = await _httpClient.SendAsync(forwardRequest); + var responseContent = await response.Content.ReadAsStringAsync(); + + var authResponse = JsonSerializer.Deserialize(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + return authResponse ?? new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null }; + } + catch (Exception ex) + { + return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null }; + } + } + + + public async Task RegisterUser(object payload, HttpContext httpContext) + { + var targetUrl = AuthUserTargetUrl; + var request = new + { + functionName = "registerUser", + payload, + reference = "register-user" + }; + + var json = JsonSerializer.Serialize(request); + + var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + try + { + var response = await _httpClient.SendAsync(forwardRequest); + var responseContent = await response.Content.ReadAsStringAsync(); + + var authResponse = JsonSerializer.Deserialize(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + return authResponse ?? new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null }; + } + catch (Exception ex) + { + return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null }; + } + } + + + public async Task GetUserDetails(object payload, HttpContext httpContext) + { + var targetUrl = AuthUserTargetUrl; + var request = new + { + functionName = "getUserDetails", + payload, + reference = "user-details" + }; + + var json = JsonSerializer.Serialize(request); + + var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + var authHeader = httpContext.Request.Headers["Authorization"].FirstOrDefault(); + if (!string.IsNullOrEmpty(authHeader)) + { + forwardRequest.Headers.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue( + "Bearer", + authHeader.Replace("Bearer ", "") + ); + } + + try + { + var response = await _httpClient.SendAsync(forwardRequest); + var responseContent = await response.Content.ReadAsStringAsync(); + + var authResponse = JsonSerializer.Deserialize(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + return authResponse ?? new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null }; + } + catch (Exception ex) + { + return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null }; + } + } + + + public async Task UpdateUser(object payload, HttpContext httpContext) + { + var targetUrl = AuthUserTargetUrl; + var request = new + { + functionName = "UpdateUser", + payload, + reference = "user-details" + }; + + var json = JsonSerializer.Serialize(request); + + var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + // Web clients authenticate via the access_token cookie, not an Authorization + // header — forward whichever is present so the auth service can read the userId. + ApplyAuthHeader(forwardRequest, httpContext); + + try + { + var response = await _httpClient.SendAsync(forwardRequest); + var responseContent = await response.Content.ReadAsStringAsync(); + + var authResponse = JsonSerializer.Deserialize(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + return authResponse ?? new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null }; + } + catch (Exception ex) + { + return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null }; + } + } + + public async Task AdminUpdateUser(object payload, HttpContext httpContext) + { + var targetUrl = AuthUserTargetUrl; + var request = new + { + functionName = "adminUpdateUser", + payload, + reference = "admin-update-user" + }; + + var json = JsonSerializer.Serialize(request); + + var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + try + { + var response = await _httpClient.SendAsync(forwardRequest); + var responseContent = await response.Content.ReadAsStringAsync(); + + var authResponse = JsonSerializer.Deserialize(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + return authResponse ?? new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null }; + } + catch (Exception ex) + { + return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null }; + } + } + + /// Forwards a recovery-manager function (forgotPassword / resetPassword) — no auth required. + private async Task ForwardToRecovery(string functionName, object payload) + { + var request = new { functionName, payload, reference = functionName }; + var json = JsonSerializer.Serialize(request); + var forwardRequest = new HttpRequestMessage(HttpMethod.Post, AuthRecoveryTargetUrl) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + try + { + var response = await _httpClient.SendAsync(forwardRequest); + var responseContent = await response.Content.ReadAsStringAsync(); + var authResponse = JsonSerializer.Deserialize(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return authResponse ?? new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null }; + } + catch (Exception ex) + { + return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null }; + } + } + + public Task ForgotPassword(object payload, HttpContext httpContext) + => ForwardToRecovery("forgotPassword", payload); + + public Task ResetPassword(object payload, HttpContext httpContext) + => ForwardToRecovery("resetPassword", payload); + + public Task ResetPasswordWithToken(object payload, HttpContext httpContext) + => ForwardToRecovery("resetPasswordWithToken", payload); + + // Confirms the caller's own password (used to unlock sensitive views). Returns { valid }. + public async Task VerifyPassword(object payload, HttpContext httpContext) + { + var request = new { functionName = "VerifyPassword", payload, reference = "verify-password" }; + var json = JsonSerializer.Serialize(request); + var forwardRequest = new HttpRequestMessage(HttpMethod.Post, AuthUserTargetUrl) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + ApplyAuthHeader(forwardRequest, httpContext); + + try + { + var response = await _httpClient.SendAsync(forwardRequest); + var responseContent = await response.Content.ReadAsStringAsync(); + var authResponse = JsonSerializer.Deserialize(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + return authResponse ?? new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null }; + } + catch (Exception ex) + { + return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null }; + } + } + + /// Sets the outgoing Bearer token from the Authorization header, the MobileAuthToken header, or the access_token cookie. + private static void ApplyAuthHeader(HttpRequestMessage req, HttpContext ctx) + { + string? token = null; + + var authHeader = ctx.Request.Headers["Authorization"].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(authHeader)) + token = authHeader.Replace("Bearer ", "").Trim(); + + if (string.IsNullOrWhiteSpace(token)) + { + var mobile = ctx.Request.Headers["MobileAuthToken"].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(mobile) && mobile.StartsWith("Bearer ")) + token = mobile.Substring("Bearer ".Length).Trim(); + } + + if (string.IsNullOrWhiteSpace(token)) + token = ctx.Request.Cookies["access_token"]; + + if (!string.IsNullOrWhiteSpace(token)) + req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + } + + + + + // new otp verfy for client + public async Task VerifyOTP(object payload, HttpContext httpContext) + { + + var data = DeserializePayload.Deserialize(payload); + + + + var targetUrl = AuthRecoveryTargetUrl; + var request = new + { + functionName = "verifyOTP", + payload, + reference = "verify-otp" + }; + + var json = JsonSerializer.Serialize(request); + + var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + try + { + var response = await _httpClient.SendAsync(forwardRequest); + var responseContent = await response.Content.ReadAsStringAsync(); + + var authResponse = JsonSerializer.Deserialize(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (authResponse == null) + throw new Exception("Empty response from auth service"); + + return authResponse; + + } + catch (Exception ex) + { + return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null }; + } + } + + //Logout user by clearing cookie + public async Task LogOut(object payload, HttpContext httpContext) + { + var targetUrl = AuthUserTargetUrl; + var request = new + { + functionName = "LogoutUser", + payload, + reference = "log-out" + }; + + var json = JsonSerializer.Serialize(request); + + var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + // Always clear cookies locally, even if the remote revoke fails — the user + // clicked logout and their browser session must end. + ClearAuthCookies(httpContext); + + try + { + var response = await _httpClient.SendAsync(forwardRequest); + var responseContent = await response.Content.ReadAsStringAsync(); + + var authResponse = JsonSerializer.Deserialize(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + return authResponse ?? new AuthResponse { StatusCode = 200, Success = true, Message = "Logged out", Data = null }; + } + catch + { + return new AuthResponse { StatusCode = 200, Success = true, Message = "Logged out", Data = null }; + } + } + + private static bool IsLocalHost(string? host) + { + return string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || + string.Equals(host, "127.0.0.1", StringComparison.OrdinalIgnoreCase); + } + + } +} diff --git a/Services/BookingService.cs b/Services/BookingService.cs new file mode 100644 index 0000000..242c57c --- /dev/null +++ b/Services/BookingService.cs @@ -0,0 +1,308 @@ +using Fitness_core_BE.DTOs.Booking; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using System.Security.Cryptography; + +namespace Fitness_core_BE.Services +{ + public class BookingService : IBookingService + { + private readonly IBookingRepository _bookingRepository; + private readonly IFacilityRepository _facilityRepository; + private readonly ICustomerSubscriptionRepository _customerSubscriptionRepository; + private readonly IUnitOfWork _uow; + private readonly UserRoleHelper _userRoleHelper; + + public BookingService( + IBookingRepository bookingRepository, + IFacilityRepository facilityRepository, + ICustomerSubscriptionRepository customerSubscriptionRepository, + IUnitOfWork uow, + UserRoleHelper userRoleHelper) + { + _bookingRepository = bookingRepository; + _facilityRepository = facilityRepository; + _customerSubscriptionRepository = customerSubscriptionRepository; + _uow = uow; + _userRoleHelper = userRoleHelper; + } + + public async Task CreateMemberBooking(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + var facilitySessionId = Guid.Parse(data["facilitySessionId"].GetString()!); + + var session = await _facilityRepository.GetFacilitySessionById(facilitySessionId) + ?? throw new KeyNotFoundException("Facility session not found"); + if (session.IsCancelled) + throw new ArgumentException("This session has been cancelled"); + if (session.StartTime <= DateTime.UtcNow) + throw new ArgumentException("This session has already started"); + + var cusId = await _userRoleHelper.GetCusId(DataExtractor.GetUserIdFromContext(httpContext)); + + if (await _bookingRepository.CustomerHasActiveBookingForSession(facilitySessionId, cusId)) + throw new ArgumentException("You already have a booking for this session"); + + var subscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(cusId) + ?? throw new ArgumentException("No active membership plan found"); + + // Null SessionsPerCycle = unlimited-access plan (existing gym behavior) -- + // no allowance to check. A set value caps how many Facility sessions this + // cycle's plan covers; SessionsCarriedOver (from a GracePeriod rollover, set at + // renewal approval -- see SubscriptionRenewalApprovalService.ApproveAsync) + // extends that cap only until SessionsCarriedOverExpiresAt. + if (subscription.CustomerPlan.SessionsPerCycle.HasValue) + { + var carriedOver = subscription.SessionsCarriedOverExpiresAt.HasValue + && subscription.SessionsCarriedOverExpiresAt.Value < DateTime.UtcNow + ? 0 + : subscription.SessionsCarriedOver; + + var allowance = subscription.CustomerPlan.SessionsPerCycle.Value + carriedOver; + if (subscription.SessionsUsedThisCycle >= allowance) + throw new ArgumentException("You've used all the sessions included in your current plan cycle"); + } + + var capacity = session.Capacity ?? session.Facility?.DefaultCapacity ?? 1; + var bookedCount = await _facilityRepository.GetActiveBookingCount(facilitySessionId); + if (bookedCount >= capacity) + throw new ArgumentException("This session is fully booked"); + + var booking = new Booking + { + FKFacilitySessionId = facilitySessionId, + FKClientId = session.FKClientId, + IsGuest = false, + FKCustomerId = cusId, + FKCustomerSubscriptionId = subscription.SubscriptionId, + PaymentStatus = "NotRequired", + Status = "Confirmed" + }; + + await _bookingRepository.CreateBooking(booking); + + if (subscription.CustomerPlan.SessionsPerCycle.HasValue) + subscription.SessionsUsedThisCycle++; + + await _uow.CommitAsync(); + + return MapToDto(booking, session, includeOtp: false, BuildShareUrl(httpContext, booking.BookingId)); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task CreateGuestBooking(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + var facilitySessionId = Guid.Parse(data["facilitySessionId"].GetString()!); + var guestName = data["guestName"].GetString() ?? throw new ArgumentException("guestName is required"); + var guestPhone = data["guestPhone"].GetString() ?? throw new ArgumentException("guestPhone is required"); + + var session = await _facilityRepository.GetFacilitySessionById(facilitySessionId) + ?? throw new KeyNotFoundException("Facility session not found"); + if (session.IsCancelled) + throw new ArgumentException("This session has been cancelled"); + if (session.StartTime <= DateTime.UtcNow) + throw new ArgumentException("This session has already started"); + + var capacity = session.Capacity ?? session.Facility?.DefaultCapacity ?? 1; + var bookedCount = await _facilityRepository.GetActiveBookingCount(facilitySessionId); + if (bookedCount >= capacity) + throw new ArgumentException("This session is fully booked"); + + var booking = new Booking + { + FKFacilitySessionId = facilitySessionId, + FKClientId = session.FKClientId, + IsGuest = true, + GuestName = guestName, + GuestPhone = guestPhone, + OtpCode = GenerateOtp(), + // Payment happens separately -- online (not wired up yet) or at the venue, + // recorded via MarkGuestBookingPaid. The booking + OTP receipt exist either way. + PaymentStatus = "Unpaid", + Status = "Confirmed" + }; + + await _bookingRepository.CreateBooking(booking); + await _uow.CommitAsync(); + + // The only time the OTP is ever returned -- this response IS the guest's receipt. + return MapToDto(booking, session, includeOtp: true, BuildShareUrl(httpContext, booking.BookingId)); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task MarkGuestBookingPaid(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + var bookingId = Guid.Parse(data["bookingId"].GetString()!); + var amountCharged = data["amountCharged"].GetDecimal(); + + var booking = await _bookingRepository.GetBookingById(bookingId) + ?? throw new KeyNotFoundException("Booking not found"); + if (!booking.IsGuest) + throw new ArgumentException("Only guest bookings take a manual payment record"); + + booking.PaymentStatus = "Paid"; + booking.AmountCharged = amountCharged; + + await _uow.CommitAsync(); + return MapToDto(booking, booking.FacilitySession, includeOtp: false); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task CheckInBooking(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + var otpCode = data["otpCode"].GetString() ?? throw new ArgumentException("otpCode is required"); + + var booking = await _bookingRepository.GetActiveBookingByOtp(otpCode) + ?? throw new KeyNotFoundException("No confirmed booking found for this code"); + + if (booking.OtpVerifiedAt.HasValue) + throw new ArgumentException("This booking has already been checked in"); + + booking.OtpVerifiedAt = DateTime.UtcNow; + booking.CheckedInAt = DateTime.UtcNow; + booking.Status = "CheckedIn"; + + await _uow.CommitAsync(); + return MapToDto(booking, booking.FacilitySession, includeOtp: false); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task CancelBooking(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + var bookingId = Guid.Parse(data["bookingId"].GetString()!); + + var booking = await _bookingRepository.GetBookingById(bookingId) + ?? throw new KeyNotFoundException("Booking not found"); + if (booking.Status == "Cancelled") + throw new ArgumentException("This booking is already cancelled"); + if (booking.Status == "CheckedIn") + throw new ArgumentException("This booking has already been checked in and can't be cancelled"); + + booking.Status = "Cancelled"; + + // Give the session allowance back -- only member bookings draw one down. + if (!booking.IsGuest && booking.FKCustomerSubscriptionId.HasValue) + { + var subscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(booking.FKCustomerId!.Value); + if (subscription != null && subscription.SubscriptionId == booking.FKCustomerSubscriptionId.Value + && subscription.CustomerPlan.SessionsPerCycle.HasValue && subscription.SessionsUsedThisCycle > 0) + { + subscription.SessionsUsedThisCycle--; + } + } + + await _uow.CommitAsync(); + return MapToDto(booking, booking.FacilitySession, includeOtp: false); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task GetMyBookings(object payload, HttpContext httpContext) + { + var cusId = await _userRoleHelper.GetCusId(DataExtractor.GetUserIdFromContext(httpContext)); + var bookings = await _bookingRepository.GetBookingsForCustomer(cusId); + return bookings.Select(b => MapToDto(b, b.FacilitySession, includeOtp: false)).ToList(); + } + + public async Task GetSessionBookings(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + var facilitySessionId = Guid.Parse(data["facilitySessionId"].GetString()!); + + var bookings = await _bookingRepository.GetBookingsForSession(facilitySessionId); + // Staff view -- never expose guest OTPs here either; the door check-in flow reads + // the OTP the guest already has, it doesn't need staff to look it up beforehand. + return bookings.Select(b => MapToDto(b, b.FacilitySession, includeOtp: false)).ToList(); + } + + public async Task GetBookingView(object payload) + { + var data = DeserializePayload.Deserialize(payload); + var bookingId = Guid.Parse(data["bookingId"].GetString()!); + + var booking = await _bookingRepository.GetBookingById(bookingId); + if (booking == null) + return null; + + // The share link itself is the credential (an unguessable GUID) -- the OTP is only + // withheld once it's already been used, same rule as every other read path. + return MapToDto(booking, booking.FacilitySession, includeOtp: !booking.OtpVerifiedAt.HasValue); + } + + private static string GenerateOtp() => RandomNumberGenerator.GetInt32(100000, 1000000).ToString(); + + // Built from the request itself (scheme/host), not a config value, so the link is + // correct in every environment (localhost during dev, whatever domain in production) + // without needing a separate "public base URL" setting kept in sync. + private static string BuildShareUrl(HttpContext httpContext, Guid bookingId) + => $"{httpContext.Request.Scheme}://{httpContext.Request.Host}/api/public/bookings/{bookingId}/view"; + + private static BookingDto MapToDto(Booking booking, FacilitySession? session, bool includeOtp, string? shareUrl = null) => new() + { + BookingId = booking.BookingId, + FacilitySessionId = booking.FKFacilitySessionId, + FacilityName = session?.Facility?.Name, + SessionStartTime = session?.StartTime, + SessionEndTime = session?.EndTime, + IsGuest = booking.IsGuest, + CustomerId = booking.FKCustomerId, + GuestName = booking.GuestName, + GuestPhone = booking.GuestPhone, + OtpCode = includeOtp ? booking.OtpCode : null, + OtpVerified = booking.OtpVerifiedAt.HasValue, + PaymentStatus = booking.PaymentStatus, + AmountCharged = booking.AmountCharged, + Status = booking.Status, + BookedAt = booking.BookedAt, + CheckedInAt = booking.CheckedInAt, + ShareUrl = shareUrl + }; + } +} diff --git a/Services/BootstrapService.cs b/Services/BootstrapService.cs new file mode 100644 index 0000000..08de149 --- /dev/null +++ b/Services/BootstrapService.cs @@ -0,0 +1,89 @@ +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using Fitness_core_BE.Models.Enums; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Services +{ + // GYM's onboarding/activation flow (ClientController's "onboarding/requests" -> + // "activation/{token}" endpoints) is a multi-gym SaaS signup wizard -- OTP checks, NIC + // upload, a payment-method + platform-billing charge step -- built for a platform onboarding + // many paying gym clients. Vijaya is a single, already-real club, not a SaaS signup; this + // is the one-time "create the club" step instead, guarded so it can only ever run once + // (before that first Client row exists) rather than being a repeatable registration path. + public class BootstrapService : IBootstrapService + { + private readonly CentralDbContext _central; + private readonly IUnitOfWork _uow; + + public BootstrapService(CentralDbContext central, IUnitOfWork uow) + { + _central = central; + _uow = uow; + } + + public async Task BootstrapClub(object payload) + { + await _uow.BeginAsync(); + try + { + if (await _central.Client.AnyAsync()) + throw new BadRequestException("The club is already set up."); + + var data = DeserializePayload.Deserialize(payload); + if (!data.ContainsKey("userId")) throw new ArgumentException("userId is required"); + if (!data.ContainsKey("clientName")) throw new ArgumentException("clientName is required"); + + var userId = Guid.Parse(data["userId"].GetString()!); + var clientName = data["clientName"].GetString()!; + + // core-api keeps its own local mirror of user identity (see CustomerRepository/ + // StaffRepository doing the same on customer/staff creation) -- Client.FKUserId + // has a real local FK to this table, not to auth-service's database, since the + // two services no longer share a connection. Idempotent: harmless if a mirror + // row already exists for this user id. + if (!await _central.Users.AnyAsync(u => u.UserId == userId)) + { + _central.Users.Add(new User + { + UserId = userId, + Email = data.ContainsKey("clientEmail") ? data["clientEmail"].GetString() : null, + PhoneNumber = data.ContainsKey("clientPhone") ? data["clientPhone"].GetString() : null, + IsActive = true + }); + } + + var client = new Client + { + ClientId = Guid.NewGuid(), + FKUserId = userId, + ClientName = clientName, + ClientPhone = data.ContainsKey("clientPhone") ? data["clientPhone"].GetString() : null, + ClientEmail = data.ContainsKey("clientEmail") ? data["clientEmail"].GetString() : null + }; + _central.Client.Add(client); + + _central.ClientBusiness.Add(new ClientBusiness + { + Bid = Guid.NewGuid(), + FKClientId = client.ClientId, + BusinessType = BusinessType.institute, + BusinessName = clientName, + IsActive = true + }); + + await _uow.CommitAsync(); + + return new { clientId = client.ClientId, clientName = client.ClientName }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + } +} diff --git a/Services/ChatService.cs b/Services/ChatService.cs new file mode 100644 index 0000000..2dabeb0 --- /dev/null +++ b/Services/ChatService.cs @@ -0,0 +1,138 @@ +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using Fitness_core_BE.Helpers; + +namespace Fitness_core_BE.Services.Chat +{ + public class ChatService : IChatService + { + private const int MAX_MESSAGE_LENGTH = 100000; + + private readonly IChatRepo _repository; + private readonly IUnitOfWork _uow; + private readonly IHttpContextAccessor _httpContextAccessor; + + public ChatService(IChatRepo repository, IUnitOfWork uow, IHttpContextAccessor httpContextAccessor) + { + _repository = repository; + _uow = uow; + _httpContextAccessor = httpContextAccessor; + } + + public async Task GetOrCreateChatAsync(object payload) + { + var httpContext = _httpContextAccessor.HttpContext; + var data = DeserializePayload.Deserialize(payload); + Guid currentUserId= DataExtractor.GetUserIdFromContext(httpContext); + Guid otherUserId = DataExtractor.GetRequiredGuid(data, "otherUserId"); + + if (currentUserId == otherUserId) + throw new InvalidOperationException("Cannot create chat with yourself."); + + await _uow.BeginAsync(); + + try + { + var existingChat = + await _repository.GetChatBetweenUsersAsync(currentUserId, otherUserId); + + if (existingChat != null && existingChat.IsAcitve) + { + await _uow.CommitAsync(); + return existingChat; + } + + var chat = new Models.Chat + { + Id = Guid.NewGuid(), + FKSenderId = currentUserId, + FKReceiverId = otherUserId + }; + + await _repository.CreateChatAsync(chat); + await _uow.CommitAsync(); + + return chat; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task SendMessageAsync(object payload) + { + var data = DeserializePayload.Deserialize(payload); + Guid chatId = DataExtractor.GetRequiredGuid(data, "chatId"); + Guid senderId = DataExtractor.GetRequiredGuid(data, "senderId"); + string message = DataExtractor.GetRequiredString(data, "message"); + string type = DataExtractor.GetRequiredString(data, "type"); + + if (string.IsNullOrWhiteSpace(message)) + throw new ArgumentException("Message cannot be empty."); + + message = message.Trim(); + + if (message.Length > MAX_MESSAGE_LENGTH) + throw new ArgumentException($"Message exceeds {MAX_MESSAGE_LENGTH} characters."); + + await _uow.BeginAsync(); + + try + { + var chat = await _repository.GetChatByIdAsync(chatId); + + if (chat == null || !chat.IsAcitve) + throw new KeyNotFoundException("Chat not found or inactive."); + + if (senderId != chat.FKSenderId && senderId != chat.FKReceiverId) + throw new UnauthorizedAccessException("User not part of this chat."); + + var chatMessage = new ChatMessages + { + Id = Guid.NewGuid(), + FKChatId = chatId, + FKSenderId = senderId, + Message = message, + Type = type, + CreatedAt = DateTime.UtcNow, + IsAcitve = true + }; + + await _repository.AddMessageAsync(chatMessage); + await _uow.CommitAsync(); + + return chatMessage; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task GetMessagesAsync(object payload) + { + var data = DeserializePayload.Deserialize(payload); + Guid chatId = DataExtractor.GetRequiredGuid(data, "chatId"); + Guid userId = DataExtractor.GetRequiredGuid(data, "userId"); + int skip = DataExtractor.GetRequiredInt(data, "skip"); + int take = DataExtractor.GetRequiredInt(data, "take"); + if (take <= 0 || take > 100) + take = 30; + + if (!await CanUserAccessChatAsync(chatId, userId)) + throw new UnauthorizedAccessException("User not allowed to view this chat."); + + return await _repository.GetMessagesAsync(chatId, skip, take); + } + + public async Task CanUserAccessChatAsync(Guid chatId, Guid userId) + { + return await _repository.IsUserInChatAsync(chatId, userId); + } + } +} diff --git a/Services/ClientInvoiceService.cs b/Services/ClientInvoiceService.cs new file mode 100644 index 0000000..e157375 --- /dev/null +++ b/Services/ClientInvoiceService.cs @@ -0,0 +1,142 @@ +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using Fitness_core_BE.Infra.UoW; +using System.Text.Json; + +namespace Fitness_core_BE.Services.ClientInvoice +{ + public class ClientInvoiceService : IClientInvoiceService + { + private readonly IClientInvoiceRepository _clientInvoiceRepository; + private readonly IUnitOfWork _uow; + + public ClientInvoiceService(IClientInvoiceRepository clientInvoiceRepository, IUnitOfWork uow) + { + _clientInvoiceRepository = clientInvoiceRepository; + _uow = uow; + } + + public async Task CreateClientInvoiceFromSubscription(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + if (!data!.ContainsKey("subscriptionId")) + { + throw new KeyNotFoundException("subscriptionId is required"); + } + + var subscriptionId = Guid.Parse(data["subscriptionId"].GetString()!); + var result = await _clientInvoiceRepository.CreateClientInvoiceFromSubscription(subscriptionId); + await _uow.CommitAsync(); + + return result; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task GetClientInvoiceById(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + + if (!data!.ContainsKey("clientInvoiceId")) + { + throw new KeyNotFoundException("clientInvoiceId is required"); + } + + var clientInvoiceId = Guid.Parse(data["clientInvoiceId"].GetString()!); + var clientInvoice = await _clientInvoiceRepository.GetClientInvoiceById(clientInvoiceId); + + if (clientInvoice == null) + { + throw new KeyNotFoundException("ClientInvoice not found"); + } + + return clientInvoice; + } + + public async Task GetClientInvoicesByClientId(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + + if (!data!.ContainsKey("clientId")) + { + throw new KeyNotFoundException("clientId is required"); + } + + var clientId = Guid.Parse(data["clientId"].GetString()!); + return await _clientInvoiceRepository.GetClientInvoicesByClientId(clientId); + } + + public async Task GetClientInvoicesByUserId(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + + if (!data!.ContainsKey("userId")) + { + throw new KeyNotFoundException("userId is required"); + } + + var userId = Guid.Parse(data["userId"].GetString()!); + return await _clientInvoiceRepository.GetClientInvoicesByUserId(userId); + } + + public async Task UpdateClientInvoiceStatus(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + if (!data!.ContainsKey("clientInvoiceId") || !data.ContainsKey("status")) + { + throw new KeyNotFoundException("clientInvoiceId and status are required"); + } + + var clientInvoiceId = Guid.Parse(data["clientInvoiceId"].GetString()!); + var status = data["status"].GetString()!; + + // Validate status + var validStatuses = new[] { "Pending", "Paid", "Failed", "Cancelled", "Refunded" }; + if (!validStatuses.Contains(status)) + { + throw new InvalidOperationException($"Invalid status. Valid statuses are: {string.Join(", ", validStatuses)}"); + } + + var result = await _clientInvoiceRepository.UpdateClientInvoiceStatus(clientInvoiceId, status); + await _uow.CommitAsync(); + + return result; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task CalculateClientInvoiceTotal(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + + if (!data!.ContainsKey("clientInvoiceId")) + { + throw new KeyNotFoundException("clientInvoiceId is required"); + } + + var clientInvoiceId = Guid.Parse(data["clientInvoiceId"].GetString()!); + var clientInvoice = await _clientInvoiceRepository.GetClientInvoiceById(clientInvoiceId); + var total = clientInvoice?.Amount ?? 0; + + return new { Total = total }; + } + } +} diff --git a/Services/ClientPaymentMethodService.cs b/Services/ClientPaymentMethodService.cs new file mode 100644 index 0000000..5a21f68 --- /dev/null +++ b/Services/ClientPaymentMethodService.cs @@ -0,0 +1,246 @@ +using Fitness_core_BE.DTOs.PaymentGateways; +using Fitness_core_BE.DTOs.PlatformBilling; +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Services +{ + // The gym owner's own card(s) for paying their HexaFitness platform subscription. + // Payment-Service is the source of truth for everything about a card (masked PAN, brand, + // expiry, default flag) — this service never caches those locally. The one thing Core + // keeps is ClientCard.CardToken, because ClientInvoice.FKClientCardId is a pre-existing + // int FK that needs a stable local row to point at; a ClientCard row is only ever created + // once Payment-Service has confirmed a card (see CompleteCardOnboardingAsync) — there's no + // local "pending" bookkeeping, since Payment-Service already tracks that itself. + public class ClientPaymentMethodService : IClientPaymentMethodService + { + private const string GatewayName = "DirectPay"; + + private readonly IPlatformBillingRepository _platformBillingRepository; + private readonly IPaymentsEnablementRepository _paymentsEnablementRepository; + private readonly IPaymentGatewayClient _paymentGatewayClient; + private readonly UserRoleHelper _userRoleHelper; + private readonly IUnitOfWork _uow; + + public ClientPaymentMethodService( + IPlatformBillingRepository platformBillingRepository, + IPaymentsEnablementRepository paymentsEnablementRepository, + IPaymentGatewayClient paymentGatewayClient, + UserRoleHelper userRoleHelper, + IUnitOfWork uow) + { + _platformBillingRepository = platformBillingRepository; + _paymentsEnablementRepository = paymentsEnablementRepository; + _paymentGatewayClient = paymentGatewayClient; + _userRoleHelper = userRoleHelper; + _uow = uow; + } + + public async Task StartCardOnboardingAsync( + HttpContext httpContext, + StartCardOnboardingRequest request, + CancellationToken ct = default) + { + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + var client = await _platformBillingRepository.GetClientByIdAsync(clientId, ct) + ?? throw new NotFoundException("Client not found."); + + var isFirstCard = (await _platformBillingRepository.GetClientCardsAsync(clientId, ct)).Count == 0; + + var response = await _paymentGatewayClient.OnboardCardAsync( + clientId, + new OnboardCardRequest + { + GatewayName = GatewayName, + CustomerId = clientId.ToString(), + CardLabel = string.IsNullOrWhiteSpace(request.CardLabel) ? "Gym billing card" : request.CardLabel, + CardholderName = client.ClientName ?? "Gym Owner", + IsDefault = isFirstCard, + ReturnUrl = request.ReturnUrl + }, + ct); + + if (response.Status == "Active") + { + // Synchronous gateway (no hosted redirect) — finalize immediately. DirectPay + // never takes this path today (always hosted/pending); kept for gateway-agnosticism. + await PersistConfirmedCardAsync(clientId, response.CardToken, isFirstCard, ct); + } + + return new StartCardOnboardingResult + { + RedirectUrl = response.RedirectUrl ?? string.Empty, + SessionId = response.GatewayRef + }; + } + + public async Task CompleteCardOnboardingAsync( + HttpContext httpContext, + string sessionId, + CancellationToken ct = default) + { + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + + // No local lookup — Payment-Service already tracks the pending onboarding by + // (gatewayName, sessionId) and verifies it belongs to this platform. + var status = await _paymentGatewayClient.GetOnboardingStatusAsync(clientId, GatewayName, sessionId, ct); + if (status.Status != "Active") + { + return new CardOnboardingStatusResult { IsComplete = false }; + } + + var existingLocal = await _platformBillingRepository.GetClientCardByTokenAsync(clientId, status.CardToken, ct); + if (existingLocal is null) + { + var isFirstCard = (await _platformBillingRepository.GetClientCardsAsync(clientId, ct)).Count == 0; + await PersistConfirmedCardAsync(clientId, status.CardToken, isFirstCard, ct); + } + + return new CardOnboardingStatusResult + { + IsComplete = true, + Card = new ClientCardDto + { + CardToken = status.CardToken, + MaskedPan = status.MaskedPan, + CardBrand = status.CardBrand, + ExpiryMonth = status.ExpiryMonth, + ExpiryYear = status.ExpiryYear, + IsDefault = status.IsDefault + } + }; + } + + public async Task> ListCardsAsync(HttpContext httpContext, CancellationToken ct = default) + { + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + var cards = await _paymentGatewayClient.ListCardsAsync(clientId, clientId.ToString(), ct); + + return cards + .Select(card => new ClientCardDto + { + CardToken = card.CardToken, + MaskedPan = card.MaskedPan, + CardBrand = card.CardBrand, + ExpiryMonth = card.ExpiryMonth, + ExpiryYear = card.ExpiryYear, + IsDefault = card.IsDefault + }) + .ToList(); + } + + public async Task RemoveCardAsync(HttpContext httpContext, string cardToken, CancellationToken ct = default) + { + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + await _paymentGatewayClient.DeactivateCardAsync(clientId, cardToken, ct); + + // Best-effort local sync: only exists to keep ClientInvoice's FK anchor consistent + // with Payment-Service, not treated as the source of truth. + var local = await _platformBillingRepository.GetClientCardByTokenAsync(clientId, cardToken, ct); + if (local is not null) + { + await _uow.BeginAsync(); + try + { + local.IsActive = false; + local.IsPrimary = false; + await _uow.CommitAsync(); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + } + + public async Task SetDefaultCardAsync(HttpContext httpContext, string cardToken, CancellationToken ct = default) + { + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + var result = await _paymentGatewayClient.SetDefaultCardAsync(clientId, cardToken, clientId.ToString(), ct); + + await _uow.BeginAsync(); + try + { + var localCards = await _platformBillingRepository.GetClientCardsAsync(clientId, ct); + foreach (var other in localCards) + { + other.IsPrimary = other.CardToken == cardToken; + } + + await _uow.CommitAsync(); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + + var details = await _paymentGatewayClient.GetCardDetailsAsync(clientId, cardToken, ct); + return new ClientCardDto + { + CardToken = result.CardToken, + MaskedPan = details.MaskedPan, + CardBrand = details.CardBrand, + ExpiryMonth = details.ExpiryMonth, + ExpiryYear = details.ExpiryYear, + IsDefault = result.IsDefault + }; + } + + public async Task GetTransactionHistoryAsync( + HttpContext httpContext, + int page, + int pageSize, + CancellationToken ct = default) + { + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + var result = await _paymentGatewayClient.ListPaymentsAsync(clientId, cardToken: null, page, pageSize, ct); + + return new TransactionHistoryResult + { + Total = result.Total, + Page = result.Page, + Items = result.Items + .Select(item => new TransactionHistoryItemDto + { + PaymentId = item.PaymentId, + AmountMinorUnits = item.AmountMinorUnits, + Currency = item.Currency, + Status = item.Status, + CreatedAtUtc = item.CreatedAtUtc + }) + .ToList() + }; + } + + private async Task PersistConfirmedCardAsync(Guid clientId, string cardToken, bool isPrimary, CancellationToken ct) + { + await _uow.BeginAsync(); + try + { + await _paymentsEnablementRepository.CreateClientCardAsync( + new ClientCard + { + FK_ClientId = clientId, + CardToken = cardToken, + IsActive = true, + IsPrimary = isPrimary + }, + ct); + + await _uow.CommitAsync(); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + } +} diff --git a/Services/ClientService.cs b/Services/ClientService.cs new file mode 100644 index 0000000..c39f46d --- /dev/null +++ b/Services/ClientService.cs @@ -0,0 +1,1342 @@ +using Fitness_core_BE.Domain; +using Fitness_core_BE.DTOs.Auth; +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using Fitness_core_BE.Models.IOs; +using System.Text.Json; + +namespace Fitness_core_BE.Services +{ + public class ClientService : IClientService + { + private readonly IClientRepository _clientRepository; + private readonly ICustomerRepository _customerRepository; + private readonly IUnitOfWork _uow; + private readonly IAuthService _authService; + private readonly ICustomerService _customerService; + private readonly ICustomerSubscriptionService _customerSubscriptionService; + private readonly IMealService _mealService; + private readonly IWorkoutService _workoutService; + private readonly UserRoleHelper _userRoleHelper; + private readonly IThirdPartyService _thirdPartyService; + private readonly IConfiguration _config; + private readonly ITermsRepository _termsRepository; + private readonly IPaymentService _paymentService; + private readonly IAccessDeviceService _accessDeviceService; + private readonly ICustomerInvoiceRepository _customerInvoiceRepository; + + public ClientService(IClientRepository _clientRepository, ICustomerRepository customerRepository, IUnitOfWork uow, IAuthService authService, ICustomerService customerService, ICustomerSubscriptionService customerSubscriptionService, IMealService mealService, IWorkoutService workoutService, UserRoleHelper userRoleHelper, IThirdPartyService thirdPartyService, IConfiguration config, ITermsRepository termsRepository, IPaymentService paymentService, IAccessDeviceService accessDeviceService, ICustomerInvoiceRepository customerInvoiceRepository) + { + this._clientRepository = _clientRepository; + _customerRepository = customerRepository; + _uow = uow; + _authService = authService; + _customerService = customerService; + _customerSubscriptionService = customerSubscriptionService; + _mealService = mealService; + _workoutService = workoutService; + _userRoleHelper = userRoleHelper; + _thirdPartyService = thirdPartyService; + _config = config; + _termsRepository = termsRepository; + _paymentService = paymentService; + _accessDeviceService = accessDeviceService; + _customerInvoiceRepository = customerInvoiceRepository; + } + + //V1 implementation + public async Task GetAllCustomers(object payload, HttpContext httpContext) + { + return await _clientRepository.GetAllCustomers(); + } + + + public async Task GetCustomerDetailsById(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + Guid customerId = Guid.Parse(data["customerId"].GetString()!); + await EnsureCustomerBelongsToCallerClient(customerId, httpContext); + + var customer = await _clientRepository.GetCustomerById(customerId); + + if (customer == null) + throw new KeyNotFoundException($"Customer with ID {customerId} not found"); + + customer.CustomerSubscriptions = null; + + // SubscriptionMembers-based: also finds subscriptions where this customer + // is a group secondary (customer.CustomerSubscriptions only sees primaries). + var subscriptions = await _customerSubscriptionService.BuildSummariesForCustomerAsync(customerId); + + // Legacy `plans` shape retained for backward compatibility. + var plans = subscriptions + .Select(s => new + { + customerPlanId = s.CustomerPlanId, + planName = s.PlanName, + planPrice = s.PlanPrice, + durationInDays = 0, + planScope = s.PlanScope, + isActive = s.IsActive + }) + .ToList(); + + return new + { + customer, + plans, + subscriptions + }; + } + + // Aggregated member profile for the "More Info" view: basic details + targets + + // weight progress (already loaded on the customer) plus the plans they follow. + // Medical details are deliberately excluded — they require a password (GetCustomerMedical). + public async Task GetCustomerFullDetails(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + Guid customerId = Guid.Parse(data["customerId"].GetString()!); + await EnsureCustomerBelongsToCallerClient(customerId, httpContext); + + var customer = await _clientRepository.GetCustomerById(customerId) + ?? throw new KeyNotFoundException($"Customer with ID {customerId} not found"); + customer.CustomerSubscriptions = null; + + var subscriptions = await _customerSubscriptionService.BuildSummariesForCustomerAsync(customerId); + var workoutPlans = await _clientRepository.GetFollowedWorkoutPlansAsync(customerId); + var mealPlans = await _clientRepository.GetFollowedMealPlansAsync(customerId); + + return new { customer, subscriptions, workoutPlans, mealPlans }; + } + + // Password-gated medical details. The owner re-enters their account password, which is + // verified against the auth service; only on success are BloodGroup/Disabilities returned. + public async Task GetCustomerMedical(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + Guid customerId = Guid.Parse(data["customerId"].GetString()!); + string password = DataExtractor.GetRequiredString(data, "password"); + await EnsureCustomerBelongsToCallerClient(customerId, httpContext); + + var verify = await _authService.VerifyPassword(new { password }, httpContext); + if (verify.StatusCode != 200 || !ExtractValid(verify.Data)) + throw new UnauthorizedException("Password is incorrect."); + + var health = await _clientRepository.GetCustomerHealthAsync(customerId); + return new + { + bloodGroup = health?.BloodGroup, + disabilities = health?.Disabilities + }; + } + + // The auth VerifyPassword returns { valid } (possibly wrapped in a data envelope). + private static bool ExtractValid(object? data) + { + if (data is not JsonElement el || el.ValueKind != JsonValueKind.Object) + return false; + if (el.TryGetProperty("valid", out var v) && + (v.ValueKind == JsonValueKind.True || v.ValueKind == JsonValueKind.False)) + return v.GetBoolean(); + if (el.TryGetProperty("data", out var d) && d.ValueKind == JsonValueKind.Object && + d.TryGetProperty("valid", out var v2) && + (v2.ValueKind == JsonValueKind.True || v2.ValueKind == JsonValueKind.False)) + return v2.GetBoolean(); + return false; + } + + //V1 implementation + public async Task DeleteCustomerByClient (object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + Guid customerId = Guid.Parse(data["customerId"].GetString()!); + await EnsureCustomerBelongsToCallerClient(customerId, httpContext); + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + + // Revoke physical access at this gym first, while the membership is still active + // in the DB (SetCustomerAccessAsync requires an active membership at clientId to + // resolve which AccessProfile to touch). No profile exists if the member never + // completed device enrollment — nothing to revoke in that case. + try + { + await _accessDeviceService.SetCustomerAccessAsync(clientId, customerId, enabled: false, reason: "Removed from gym"); + } + catch (NotFoundException) + { + } + + // Client-initiated delete only deactivates this customer's subscriptions/plan + // assignments at THIS gym — the shared central Customer record (and any + // membership at another gym) must never be touched by a single client's delete. + var result = await _customerRepository.DeactivateCustomerForClient(customerId, clientId); + if (!result) + { + throw new KeyNotFoundException("Customer Not Found"); + } + await _uow.CommitAsync(); + + return new { success = true, message = "Customer deleted successfully" }; + } + catch + { + await _uow.RollbackAsync(); + throw new KeyNotFoundException("Error"); + } + + } + + public async Task ClientLogOut (object payload, HttpContext httpContext) + { + try + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + var authPayload = new { userId = userId }; + + AuthResponse authResponse = await _authService.LogOut(authPayload, httpContext); + if (!authResponse.Success) + { + throw new InvalidOperationException(authResponse.Message); + } + + return new { success = true, message = "Logged out successfully", auth = authResponse }; + } + catch + { + throw; + } + } + + + + public async Task GetClientList(object payload, HttpContext httpContext) + { + try + { + var clients = await _clientRepository.GetAllClientsAsync(); + return clients; + } + catch + { + throw; + } + } + public async Task GetClientById(object payload, HttpContext httpContext) + { + try + { + var data = DeserializePayload.Deserialize(payload); + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + var client = await _clientRepository.GetClientByIdAsync(clientId); + + if (client == null) + throw new KeyNotFoundException("Client not found"); + + return client; + } + catch + { + throw; + } + } + + public async Task UpdateGymProfile( + UpdateGymProfileRequest request, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + await _uow.BeginAsync(); + try + { + var business = await _clientRepository.GetPrimaryBusinessByClientIdAsync(clientId) + ?? throw new KeyNotFoundException("Gym business profile not found"); + + if (request.BusinessName != null) business.BusinessName = request.BusinessName.Trim(); + if (request.Address != null) business.Address = request.Address.Trim(); + if (request.City != null) business.City = request.City.Trim(); + if (request.Description != null) business.Description = request.Description.Trim(); + + // AC + equipment live as JSON in Facilities; preserve whichever isn't sent. + if (request.HasAc.HasValue || request.Equipment != null) + { + var current = GymFacilities.Parse(business.Facilities); + var hasAc = request.HasAc ?? current.HasAc; + var equipment = request.Equipment == null + ? current.Equipment + : request.Equipment + .Select(item => new EquipmentCount(item.Name, item.Count)) + .ToList(); + business.Facilities = GymFacilities.Serialize(hasAc, equipment); + } + + await _uow.CommitAsync(); + var savedFacilities = GymFacilities.Parse(business.Facilities); + return new + { + success = true, + message = "Gym profile updated", + facilities = new + { + hasAc = savedFacilities.HasAc, + equipment = savedFacilities.Equipment + } + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + // Gym-owner view of their own gym's active Terms & Conditions (for editing). + public async Task GetMyGymTerms(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + var terms = await _termsRepository.GetActiveByClientIdAsync(clientId); + if (terms == null) + return new { hasTerms = false }; + + return new { hasTerms = true, terms.Version, terms.Content, terms.CreatedAt }; + } + + // Publishes a new Terms version for the gym owner's own gym; deactivates the old one. + public async Task PublishGymTerms(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + string content = DataExtractor.GetRequiredString(data, "content"); + + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + var currentTerms = await _termsRepository.GetActiveForUpdateAsync(clientId); + foreach (var current in currentTerms) + current.IsActive = false; + + var latestVersion = await _termsRepository.GetLatestVersionAsync(clientId); + var terms = new ClientTerms + { + ClientTermsId = Guid.NewGuid(), + FKClientId = clientId, + Version = latestVersion + 1, + Content = content, + IsActive = true, + CreatedAt = DateTime.UtcNow + }; + await _termsRepository.AddTermsAsync(terms); + await _uow.SaveChangesAsync(); + return new { success = true, terms.Version, terms.CreatedAt }; + } + + public async Task UploadGymLogo(IFormFile logo, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + if (logo == null || logo.Length == 0) + throw new BadRequestException("No logo file provided."); + + string extension = Path.GetExtension(logo.FileName).ToLowerInvariant(); + string[] allowed = { ".jpg", ".jpeg", ".png", ".webp" }; + if (!allowed.Contains(extension)) + throw new BadRequestException("Logo must be an image (jpg, png, webp)."); + if (logo.Length > 5 * 1024 * 1024) + throw new BadRequestException("Logo too large (max 5MB)."); + + string uploadsRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "Uploads", "gym-logos", clientId.ToString())); + if (!Directory.Exists(uploadsRoot)) + Directory.CreateDirectory(uploadsRoot); + + string filename = $"logo_{DateTime.UtcNow:yyyyMMdd_HHmmss}{extension}"; + string filePath = Path.Combine(uploadsRoot, filename); + using (var stream = new FileStream(filePath, FileMode.Create)) + { + await logo.CopyToAsync(stream); + } + + await _uow.BeginAsync(); + try + { + var business = await _clientRepository.GetPrimaryBusinessByClientIdAsync(clientId) + ?? throw new KeyNotFoundException("Gym business profile not found"); + business.BusinessLogoId = $"/Uploads/gym-logos/{clientId}/{Uri.EscapeDataString(filename)}"; + await _uow.CommitAsync(); + return new { businessLogoId = business.BusinessLogoId }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task GetGymLogo(HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + var business = await _clientRepository.GetPrimaryBusinessByClientIdAsync(clientId); + var logo = await BusinessLogoHelper.ReadAsDataUrlAsync(business?.BusinessLogoId); + return new { logo }; + } + + // Sends an OTP to the owner's new email/phone before it replaces the current one. + public async Task SendContactOtp(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + string identifier = DataExtractor.GetRequiredString(data, "identifier"); + + var authResponse = await _authService.SendOtp(new { identifier, newUser = true }, httpContext); + if (!authResponse.Success) + throw new BadRequestException($"Failed to send OTP: {authResponse.Message}"); + + return AuthOtpResponseParser.ToSendOtpResult(authResponse); + } + + // Verifies the OTP, writes the new value to the auth DB (UpdateUser), then mirrors it + // onto the local Client record so the dashboard shows the change immediately. + public async Task VerifyContactOtp(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + string type = DataExtractor.GetRequiredString(data, "type").ToLowerInvariant(); + string identifier = DataExtractor.GetRequiredString(data, "identifier"); + string referenceNumber = DataExtractor.GetRequiredString(data, "referenceNumber"); + string otpCode = DataExtractor.GetRequiredString(data, "otpCode"); + + if (type != "email" && type != "phone") + throw new BadRequestException("Contact type must be 'email' or 'phone'."); + + var verify = await _authService.VerifyOTP(new { referenceNumber, otpCode, newUser = true }, httpContext); + if (!verify.Success) + throw new BadRequestException($"OTP verification failed: {verify.Message}"); + + object updatePayload = type == "email" ? new { email = identifier } : new { mobileNumber = identifier }; + var update = await _authService.UpdateUser(updatePayload, httpContext); + if (!update.Success) + throw new BadRequestException($"Failed to update account: {update.Message}"); + + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + await _uow.BeginAsync(); + try + { + var client = await _clientRepository.GetClientEntityByIdAsync(clientId) + ?? throw new KeyNotFoundException("Client not found"); + if (type == "email") client.ClientEmail = identifier; + else client.ClientPhone = identifier; + await _uow.CommitAsync(); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + + return new { success = true, message = type == "email" ? "Email updated" : "Phone number updated" }; + } + + + + public async Task CreateCustomerWithPlans(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + var clientId = await _userRoleHelper.GetClientId( + DataExtractor.GetUserIdFromContext(httpContext)); + var mobileNumber = DataExtractor.GetRequiredString(data, "mobileNumber"); + var email = DataExtractor.GetOptionalString(data, "email"); + var confirmLink = DataExtractor.GetOptionalBool(data, "confirmLink") ?? false; + + // Reject outright if this gym already has this phone/email as an active member -- + // re-adding them here would otherwise silently stack a duplicate subscription. + var ownGymDuplicate = await _customerRepository.FindActiveLinkByIdentifierAsync(mobileNumber) ?? + (!string.IsNullOrWhiteSpace(email) + ? await _customerRepository.FindActiveLinkByIdentifierAsync(email) + : null); + if (ownGymDuplicate != null) + throw new ArgumentException("This phone number or email is already used by another customer at your gym."); + + var existingCustomer = + await _customerRepository.GetCustomerByIdentifier(mobileNumber) ?? + (!string.IsNullOrWhiteSpace(email) + ? await _customerRepository.GetCustomerByIdentifier(email) + : null); + + // Not at this gym, but already a platform customer elsewhere -- don't auto-link; + // let staff confirm this is really the same person first. Nothing written yet. + if (existingCustomer != null && !confirmLink) + { + return new + { + success = true, + requiresConfirmation = true, + message = "This phone number or email already belongs to an existing HexaFitness customer. Add them as your member?", + existingCustomer = new + { + existingCustomer.CusId, + existingCustomer.CusName, + existingCustomer.PrimaryPH, + existingCustomer.Email + } + }; + } + + await _uow.BeginAsync(); + try + { + if (existingCustomer != null) + { + existingCustomer.IsActive = true; + if (existingCustomer.User != null) + { + existingCustomer.User.IsActive = true; + existingCustomer.User.UserName = DataExtractor.GetRequiredString(data, "userName"); + existingCustomer.User.PhoneNumber = mobileNumber; + if (!string.IsNullOrWhiteSpace(email)) + existingCustomer.User.Email = email; + } + + existingCustomer.CusName = DataExtractor.GetRequiredString(data, "fullname"); + existingCustomer.Gender = DataExtractor.GetRequiredString(data, "gender"); + existingCustomer.BoD = DataExtractor.GetOptionalDateOfBirth(data, "birthday"); + existingCustomer.Email = email; + existingCustomer.PrimaryPH = mobileNumber; + existingCustomer.NIC = DataExtractor.GetOptionalString(data, "nic"); + existingCustomer.Address = DataExtractor.GetOptionalString(data, "address"); + + await _uow.SaveChangesAsync(); + // The central Customer may predate this gym entirely (same person already + // a member elsewhere), in which case this tenant DB has no link row yet. + await _customerRepository.EnsureTenantCustomerLink(existingCustomer); + await _uow.SaveChangesAsync(); + await _accessDeviceService.EnsureAccessProfileAsync( + clientId, + existingCustomer.User?.UserId ?? Guid.Empty, + "Customer", + existingCustomer.CusName ?? existingCustomer.User?.UserName ?? string.Empty); + + Guid? existingSubscriptionId = null; + Guid? existingInvoiceId = null; + decimal? existingInvoiceAmount = null; + if ((data.ContainsKey("workoutPlanIds") && data["workoutPlanIds"].ValueKind == JsonValueKind.Array) || + (data.ContainsKey("mealPlanIds") && data["mealPlanIds"].ValueKind == JsonValueKind.Array) || + data.ContainsKey("customerPlanId")) + { + var assignResult = await _customerService.AssignPlansToCustomer(existingCustomer.CusId, payload, httpContext); + var subscriptionIdProp = assignResult?.GetType().GetProperty("subscriptionId"); + existingSubscriptionId = subscriptionIdProp?.GetValue(assignResult) as Guid?; + var invoiceIdProp = assignResult?.GetType().GetProperty("invoiceId"); + existingInvoiceId = invoiceIdProp?.GetValue(assignResult) as Guid?; + var invoiceAmountProp = assignResult?.GetType().GetProperty("invoiceAmount"); + existingInvoiceAmount = invoiceAmountProp?.GetValue(assignResult) as decimal?; + } + + await _uow.CommitAsync(); + + return new + { + success = true, + message = "Customer already existed in core DB and was reactivated.", + customer = existingCustomer, + subscriptionId = existingSubscriptionId, + invoiceId = existingInvoiceId, + invoiceAmount = existingInvoiceAmount, + paymentLinkSent = false, + paymentLinkError = (string?)null, + auth = new + { + skipped = true + } + }; + } + + var user = new User + { + UserName = DataExtractor.GetRequiredString(data, "userName"), + PhoneNumber = mobileNumber, + Email = email, + IsActive = true, + CreateDate = DateTime.UtcNow + }; + + var customer = new Customer + { + CusName = DataExtractor.GetRequiredString(data, "fullname"), + Gender = DataExtractor.GetRequiredString(data, "gender"), + BoD = DataExtractor.GetOptionalDateOfBirth(data, "birthday"), + Email = email, + PrimaryPH = mobileNumber, + NIC = DataExtractor.GetOptionalString(data, "nic"), + Address = DataExtractor.GetOptionalString(data, "address"), + IsPrimaryPHVerfied = false, + User = user + }; + + var result = await _customerRepository.CreateCustomer(customer); + await _uow.SaveChangesAsync(); + // Must precede AssignPlansToCustomer: the tenant-side subscription FKs point + // at this link row, so it has to be persisted before any of them are inserted. + await _customerRepository.EnsureTenantCustomerLink(result); + await _uow.SaveChangesAsync(); + await _accessDeviceService.EnsureAccessProfileAsync( + clientId, user.UserId, "Customer", customer.CusName ?? user.UserName ?? string.Empty); + + var tempPassword = Guid.NewGuid().ToString("N").Substring(0, 8); + + AuthResponse authResponse; + try + { + + Guid customerRoleId = Guid.Parse("08de6a11-9e9f-4401-8a10-685cc57b41ed"); + Guid customerUserTypeId = Guid.Parse("00000000-0000-0000-0000-000000000001"); + + var authPayload = DeserializePayload.Deserialize(payload); + authPayload["userId"] = JsonSerializer.SerializeToElement(user.UserId); + authPayload["roleId"] = JsonSerializer.SerializeToElement(customerRoleId); + authPayload["userTypeId"] = JsonSerializer.SerializeToElement(customerUserTypeId); + authPayload["password"] = JsonSerializer.SerializeToElement(tempPassword); + authPayload["mobileNumber"] = JsonSerializer.SerializeToElement(user.PhoneNumber); + authResponse = await _authService.RegisterUser(authPayload, httpContext); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Auth registration failed: {ex.Message}"); + } + + if (authResponse.StatusCode != 200 || !authResponse.Success) + { + throw new InvalidOperationException($"Auth registration failed: {authResponse.Message}"); + } + + Guid? subscriptionId = null; + Guid? invoiceId = null; + decimal? invoiceAmount = null; + if ((data.ContainsKey("workoutPlanIds") && data["workoutPlanIds"].ValueKind == JsonValueKind.Array) || + (data.ContainsKey("mealPlanIds") && data["mealPlanIds"].ValueKind == JsonValueKind.Array) || + data.ContainsKey("customerPlanId")) + { + var assignResult = await _customerService.AssignPlansToCustomer(result.CusId, payload, httpContext); + + // The FE opens the "add group members" step right after creating a + // member on a Group plan, so surface the new subscription's id. + var subscriptionIdProp = assignResult?.GetType().GetProperty("subscriptionId"); + subscriptionId = subscriptionIdProp?.GetValue(assignResult) as Guid?; + var invoiceIdProp = assignResult?.GetType().GetProperty("invoiceId"); + invoiceId = invoiceIdProp?.GetValue(assignResult) as Guid?; + var invoiceAmountProp = assignResult?.GetType().GetProperty("invoiceAmount"); + invoiceAmount = invoiceAmountProp?.GetValue(assignResult) as decimal?; + } + + await _uow.CommitAsync(); + + // Send the member their app login (Gmail email + SMS with the mobile-app + // link, username and password). Non-critical: delivery failures must not + // roll back the created customer. + await SendCustomerWelcomeAsync(user.UserName, user.Email, user.PhoneNumber, tempPassword); + + bool paymentLinkSent = false; + string? paymentLinkError = null; + if (invoiceId.HasValue && invoiceAmount is > 0) + { + try + { + await _paymentService.SendPaymentLink(invoiceId.Value, httpContext); + paymentLinkSent = true; + } + catch (Exception ex) + { + // Account/subscription creation is complete. Delivery can be retried + // from the member invoice without rolling the member back. + paymentLinkError = ex.Message; + Console.WriteLine($"Automatic customer payment link failed (non-critical): {ex.Message}"); + } + } + + return new + { + success = true, + message = "Customer created and plans assigned successfully", + customer = result, + subscriptionId, + invoiceId, + paymentLinkSent, + paymentLinkError, + auth = authResponse + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + // Delivers a newly created member's app login over both channels. Each channel is + // isolated so one failing (e.g. no email configured) still lets the other through, + // and any failure is swallowed — the member already exists at this point. + private async Task SendCustomerWelcomeAsync(string? userName, string? email, string? phoneNumber, string password) + { + var appLink = _config["App:MobileAppLink"] ?? ""; + + if (!string.IsNullOrWhiteSpace(email)) + { + try + { + string html = + $"

Hi {userName},

" + + "

Your gym membership account has been created. Use the credentials below to log in to the Hexa Fitness mobile app:

" + + $"

Username: {userName}
Password: {password}

" + + (string.IsNullOrWhiteSpace(appLink) ? "" : $"

Download the app: {appLink}

") + + "

We recommend changing your password after your first login.

" + + "

— Hexa Fitness

"; + await _thirdPartyService.SendEmailAsync(email!, "Your Hexa Fitness app login", html); + } + catch (Exception ex) + { + Console.WriteLine($"Customer welcome email failed (non-critical): {ex.Message}"); + } + } + + if (!string.IsNullOrWhiteSpace(phoneNumber)) + { + try + { + string sms = + $"Welcome to Hexa Fitness!" + + (string.IsNullOrWhiteSpace(appLink) ? "" : $" Download: {appLink}"); + await _thirdPartyService.SendSmsAsync(phoneNumber!, null, sms); + } + catch (Exception ex) + { + Console.WriteLine($"Customer welcome SMS failed (non-critical): {ex.Message}"); + } + } + } + + + public async Task UpdateCustomerByClient(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + Guid customerId = DataExtractor.GetRequiredGuid(data, "customerId"); + await EnsureCustomerBelongsToCallerClient(customerId, httpContext); + + var customer = await _customerRepository.GetCustomerById(customerId); + if (customer == null) + throw new NotFoundException("Customer not found"); + + customer.CusName = DataExtractor.GetRequiredString(data, "fullname"); + customer.Gender = DataExtractor.GetRequiredString(data, "gender"); + customer.BoD = DataExtractor.GetOptionalDateOfBirth(data, "birthday"); + customer.Email = DataExtractor.GetOptionalString(data, "email"); + customer.PrimaryPH = DataExtractor.GetOptionalString(data, "phoneNumber"); + customer.NIC = DataExtractor.GetOptionalString(data, "nic"); + customer.Address = DataExtractor.GetOptionalString(data, "address"); + + var userName = DataExtractor.GetRequiredString(data, "userName"); + if (customer.User != null) + { + customer.User.UserName = userName; + customer.User.Email = customer.Email; + customer.User.PhoneNumber = customer.PrimaryPH; + } + + var result = await _customerRepository.UpdateCustomer(customerId, customer); + if (result == null) + throw new KeyNotFoundException("Customer not found"); + + // This gym's own TenantCustomerLink caches name/phone/email for list and payment + // views -- without this it keeps showing whatever was cached at link time, drifting + // from the central edit above. EnsureTenantCustomerLink is idempotent, so this is + // just a refresh when the link already exists. + await _customerRepository.EnsureTenantCustomerLink(result); + + // A client-side profile edit must not silently drift from what the customer logs + // in with — push the same name/contact fields to their Auth login credential. + // Failure here throws (caught below), rolling back the local edit too, so the + // two never end up out of sync in either direction. + var authResponse = await _authService.AdminUpdateUser(new + { + userId = customer.FKUserId, + userName, + email = customer.Email, + mobileNumber = customer.PrimaryPH + }, httpContext); + if (authResponse.StatusCode != 200 || !authResponse.Success) + throw new InvalidOperationException($"Auth profile sync failed: {authResponse.Message}"); + + if ((data.ContainsKey("workoutPlanIds") && data["workoutPlanIds"].ValueKind == JsonValueKind.Array) || + (data.ContainsKey("mealPlanIds") && data["mealPlanIds"].ValueKind == JsonValueKind.Array) || + data.ContainsKey("customerPlanId")) + { + await _customerService.AssignPlansToCustomer(customerId, payload, httpContext); + } + + await _uow.CommitAsync(); + return result; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + + + public async Task AssignPlanToExistingCustomer(Guid customerId, object payload, HttpContext httpContext) + { + await EnsureCustomerBelongsToCallerClient(customerId, httpContext); + + var data = DeserializePayload.Deserialize(payload); + if (!data.ContainsKey("customerPlanId") || data["customerPlanId"].ValueKind == JsonValueKind.Null) + throw new ArgumentException("customerPlanId is required."); + + return await _customerService.AssignPlansToCustomer(customerId, payload, httpContext); + } + + // Switches a customer already on an Individual-scope plan to a different Individual-scope + // plan: deactivates their current subscription (and its members/subitems/installments) + // and starts a fresh one via CustomerSubscriptionFactory, mirroring how AssignPlansToCustomer + // builds a first subscription — but self-contained here rather than reusing that method, + // since it assumes the customer has no existing active subscription. + // + // Settles the old plan's billing rather than abandoning it: + // - old invoice still Pending/Partial -> mark it RolledOver and carry the unpaid + // balance onto a full-price invoice for the new plan (same mechanism the renewal + // sweep already uses for exactly this). + // - old invoice Paid, new plan costs more -> a single settlement invoice for just the + // difference (not the new plan's full price plus the difference). + // - old invoice Paid, new plan costs less -> the same settlement invoice, but negative: + // a "Credit" invoice the customer's next bill (any kind) will automatically deduct. + public async Task ChangeCustomerMembershipPlan(Guid customerId, Guid newCustomerPlanId, HttpContext httpContext) + { + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + await EnsureCustomerBelongsToCallerClient(customerId, httpContext); + + await _uow.BeginAsync(); + try + { + var newPlan = await _clientRepository.GetCustomerPlanEntityForClientAsync(newCustomerPlanId, clientId) + ?? throw new NotFoundException("Membership plan not found."); + if (newPlan.PlanScope != "Individual") + throw new BadRequestException("Change Membership only supports Individual-scope plans."); + + var oldMembership = await _clientRepository.GetActiveMembershipForCustomerAsync(customerId); + if (oldMembership?.Subscription.FKCustomerPlanId == newCustomerPlanId) + throw new BadRequestException("This member is already on that plan."); + + // Read the old subscription's billing state before ReplaceCustomerSubscriptionAsync + // deactivates it -- GetInvoiceBySubscriptionIdAsync still resolves correctly on an + // active subscription regardless of what happens to it a moment later. + var oldInvoice = oldMembership != null + ? await _customerInvoiceRepository.GetInvoiceBySubscriptionIdAsync(oldMembership.FKSubscriptionId) + : null; + + var subscription = new Models.CustomerSubscription + { + FKCustomerPlanId = newCustomerPlanId, + FKCustomerId = customerId, + OnboardDate = DateTime.UtcNow, + IsActive = true + }; + var initialSubItem = new Models.CustomerSubItem + { + SubItemName = "Membership change", + IsActive = true + }; + var aggregate = CustomerSubscriptionFactory.Create(subscription, newPlan, initialSubItem); + + await _clientRepository.ReplaceCustomerSubscriptionAsync(oldMembership, aggregate); + await _uow.SaveChangesAsync(); + + // Sibling namespace Fitness_core_BE.Services.CustomerInvoice (CustomerInvoiceService.cs) + // outranks a bare `CustomerInvoice` here, so the model type needs its full name. + Fitness_core_BE.Models.CustomerInvoice invoice; + if (oldInvoice == null || oldInvoice.Status == "Pending" || oldInvoice.Status == "Partial") + { + decimal carriedOverBalance = 0m; + if (oldInvoice != null) + { + var approvedPaid = await _customerInvoiceRepository.GetApprovedPaymentTotalAsync(oldInvoice.CustomerInvoiceId); + carriedOverBalance = Math.Max(0m, oldInvoice.Amount - approvedPaid); + await _customerInvoiceRepository.UpdateCustomerInvoiceStatus(oldInvoice.CustomerInvoiceId, "RolledOver"); + } + + invoice = await _customerInvoiceRepository.CreateCustomerInvoiceFromSubscription( + subscription.SubscriptionId, chargePayment: true, carriedOverBalance); + } + else + { + // oldInvoice is Paid (any other status would mean the old subscription was + // never properly billed, which "shouldn't happen" for an active membership -- + // treated the same as Paid here rather than adding a third branch for it). + var diff = newPlan.PlanPrice - oldInvoice.Amount; + invoice = await _customerInvoiceRepository.CreatePlanChangeInvoiceAsync( + subscription.SubscriptionId, "Membership change", diff); + } + + await _uow.CommitAsync(); + + return new + { + success = true, + message = "Membership plan changed", + subscriptionId = subscription.SubscriptionId, + invoiceId = invoice.CustomerInvoiceId, + invoiceAmount = invoice.Amount, + invoiceStatus = invoice.Status + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + // Drops the member back to "no plan": if they're the primary member of a subscription, + // the whole subscription (and every other member on it) is deactivated; otherwise only + // their own membership row is. No new subscription/invoice is created. + public async Task UnassignCustomerMembership(Guid customerId, HttpContext httpContext) + { + await EnsureCustomerBelongsToCallerClient(customerId, httpContext); + + await _uow.BeginAsync(); + try + { + var membership = await _clientRepository.GetMembershipForCustomerAsync(customerId) + ?? throw new BadRequestException("This member doesn't have a plan to unassign."); + + // Toggle: the same button unassigns an assigned member and re-assigns them + // to the plan they were already on. The subscription itself is left active + // either way, so nothing has to be rebuilt to put them back. + var assigned = !membership.IsPlanAssigned; + + // Unassigning the primary takes the whole group off the shared plan (it is one + // entitlement); a secondary only unassigns themselves. + await _clientRepository.SetMembershipPlanAssignedAsync( + membership.FKSubscriptionId, + assigned, + wholeSubscription: membership.IsPrimary, + membership.SubscriptionMemberId); + + await _uow.SaveChangesAsync(); + await _uow.CommitAsync(); + + return new + { + success = true, + isPlanAssigned = assigned, + message = assigned ? "Membership assigned" : "Membership unassigned" + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task GetCustomersByClientId(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + var subscriptions = await _clientRepository.GetCustomersByClientIdAsync(clientId); + + return subscriptions; + } + + private async Task EnsureCustomerBelongsToCallerClient(Guid customerId, HttpContext httpContext) + { + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + if (!await _clientRepository.CustomerBelongsToClientAsync(customerId, clientId)) + throw new ForbiddenException("This customer belongs to another gym."); + } + + public async Task GetCustomerPlansByClientId(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + var plans = await _clientRepository.GetCustomerPlansByClientIdAsync(clientId); + return plans; + } + + public async Task RegisterClient(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + // Helper to extract double values safely + double? GetDouble(string key) + { + if (data.ContainsKey(key) && data[key].ValueKind == JsonValueKind.Number) + return data[key].GetDouble(); + if (data.ContainsKey(key) && data[key].ValueKind == JsonValueKind.String && + double.TryParse(data[key].GetString(), out var result)) + return result; + return null; + } + + // Create User first + var user = new User + { + UserName = data.ContainsKey("ClientName") ? data["ClientName"].GetString() : "unknown", + Email = data.ContainsKey("ClientEmail") ? data["ClientEmail"].GetString() : null, + PhoneNumber = data.ContainsKey("ClientPhone") ? data["ClientPhone"].GetString() : null, + IsActive = true, + CreateDate = DateTime.UtcNow + }; + + var client = new Client + { + ClientName = data.ContainsKey("ClientName") ? data["ClientName"].GetString() : null, + ClientPhone = data.ContainsKey("ClientPhone") ? data["ClientPhone"].GetString() : null, + ClientEmail = data.ContainsKey("ClientEmail") ? data["ClientEmail"].GetString() : null, + User = user + }; + + await _clientRepository.RegisterClientAsync(client); + + + + var clientBussiness = new ClientBusiness + { + FKClientId = client.ClientId, + BusinessType = data.ContainsKey("BusinessType") ? (Fitness_core_BE.Models.Enums.BusinessType)Enum.Parse(typeof(Fitness_core_BE.Models.Enums.BusinessType), data["BusinessType"].GetString() ?? "Individual") : Fitness_core_BE.Models.Enums.BusinessType.institute, + BusinessName = data.ContainsKey("BusinessName") ? data["BusinessName"].GetString() : null, + BusinessLogoId = data.ContainsKey("BusinessLogoId") ? data["BusinessLogoId"].GetString() : null, + Description = data.ContainsKey("Description") ? data["Description"].GetString() : null, + IsPysicalLocation = data.ContainsKey("IsPysicalLocation") ? data["IsPysicalLocation"].GetBoolean() : true, + Address = data.ContainsKey("Address") ? data["Address"].GetString() : null, + City = data.ContainsKey("City") ? data["City"].GetString() : null, + Facilities = data.ContainsKey("facilities") ? data["facilities"].GetString() : null, + CoachingMode = data.ContainsKey("CoachingMode") ? data["CoachingMode"].GetString() : null, + Expertise = data.ContainsKey("Expertise") ? data["Expertise"].GetString() : null, + Gender = data.ContainsKey("Gender") ? data["Gender"].GetString() : null, + ExperienceYears = data.ContainsKey("ExperienceYears") ? data["ExperienceYears"].GetString() : null, + SessionType = data.ContainsKey("SessionType") ? data["SessionType"].GetString() : null, + StoreType = data.ContainsKey("StoreType") ? data["StoreType"].GetString() : null, + ProductCategory = data.ContainsKey("ProductCategory") ? data["ProductCategory"].GetString() : null, + DeliveryAvailable = data.ContainsKey("DeliveryAvailable") ? data["DeliveryAvailable"].GetString() : null, + + Latitude = GetDouble("latitude"), + Longitude = GetDouble("longitude"), + }; + + Guid userTypeId = Guid.Parse("00000000-0000-0000-0000-000000000002"); + Guid roleId = Guid.Parse("08de6a11-9e9f-4401-8a10-6859860b41ed"); + Guid userId = user.UserId; + + + await _clientRepository.RegisterBussinessDetailAsync(clientBussiness); + await _uow.CommitAsync(); + + var authPayload = new { email = client.ClientEmail, userTypeId = userTypeId, roleId = roleId, userId}; + + AuthResponse authResponse; + try + { + authResponse = await _authService.RegisterUser(authPayload, httpContext); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Auth registration failed: {ex.Message}"); + } + + if (authResponse.StatusCode != 200 || !authResponse.Success) + { + throw new InvalidOperationException($"Auth registration failed: {authResponse.Message}"); + } + + return new + { + client, + auth = authResponse + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task GetPackageList(object payload, HttpContext httpContext) + { + try + { + var packages = await _clientRepository.GetPackageListAsync(); + return packages; + } + catch + { + throw; + } + } + + public async Task GetAllCustomerPlans(object payload, HttpContext httpContext) + { + try + { + var data = DeserializePayload.Deserialize(payload); + + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + var plans = await _clientRepository.GetCustomerPlansByClientIdAsync(clientId); + return plans; + } + catch + { + throw; + } + } + + public async Task GetCustomerPlanById(object payload, HttpContext httpContext) + { + try + { + var data = DeserializePayload.Deserialize(payload); + Guid customerPlanId = DataExtractor.GetRequiredGuid(data, "customerPlanId"); + + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + var plan = await _clientRepository.GetCustomerPlanByIdForClientAsync(customerPlanId, clientId); + + if (plan == null) + throw new NotFoundException($"Customer plan with ID {customerPlanId} not found"); + + return plan; + } + catch + { + throw; + } + } + + public async Task GetMealPlansByClientId(object payload, HttpContext httpContext) + { + try + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + var plans = await _clientRepository.GetMealPlansByClientIdAsync(clientId); + return plans; + } + catch + { + throw; + } + } + + public async Task GetMealPlanById(object payload, HttpContext httpContext) + { + try + { + var data = DeserializePayload.Deserialize(payload); + Guid mealPlanId = Guid.Parse(data["mealPlanId"].GetString()!); + + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + var plan = await _clientRepository.GetMealPlanByIdForClientAsync(mealPlanId, clientId); + + if (plan == null) + throw new NotFoundException($"Meal plan with ID {mealPlanId} not found"); + + if (plan.MealItems != null) + { + foreach (var item in plan.MealItems) + { + var itemAsString = item.MealItems as string; + if (!string.IsNullOrEmpty(itemAsString)) + { + try + { + item.MealItems = System.Text.Json.JsonSerializer.Deserialize(itemAsString); + } + catch + { + // keep as string if parsing fails + } + } + } + } + + return plan; + } + catch + { + throw; + } + } + + public async Task GetWorkoutPlansByClientId(object payload, HttpContext httpContext) + { + try + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + var plans = await _clientRepository.GetWorkoutPlansByClientIdAsync(clientId); + return plans; + } + catch + { + throw; + } + } + + public async Task GetWorkoutPlanById(object payload, HttpContext httpContext) + { + try + { + var data = DeserializePayload.Deserialize(payload); + Guid workoutPlanId = Guid.Parse(data["workoutPlanId"].GetString()!); + + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + var plan = await _clientRepository.GetWorkoutPlanByIdForClientAsync(workoutPlanId, clientId); + + if (plan == null) + throw new Exceptions.NotFoundException($"Workout plan with ID {workoutPlanId} not found"); + + var transformedWorkoutItems = plan.WorkoutItems?.Select(item => + { + object? exercisesObj = null; + if (!string.IsNullOrEmpty(item.Exercises)) + { + try + { + exercisesObj = System.Text.Json.JsonSerializer.Deserialize(item.Exercises); + } + catch + { + exercisesObj = item.Exercises; + } + } + + return new + { + item.WorkoutItemId, + item.ExerciseDay, + item.Description, + Exercises = exercisesObj, + item.HowToDo, + item.DoURL, + item.WorkoutPlanId, + item.IsActive, + item.CreateDate, + item.UpdatedAt + }; + }).ToList(); + + return new + { + plan.WorkoutPlanId, + plan.Name, + plan.Description, + plan.IsTemplate, + plan.WorkoutCategoryId, + plan.WorkoutCategoryName, + plan.ClientId, + plan.ClientName, + plan.IsActive, + plan.CreateDate, + WorkoutItems = transformedWorkoutItems, + AssignedCustomers = plan.AssignedCustomers + }; + } + catch + { + throw; + } + } + + public async Task GetCardDetails(object payload, HttpContext httpContext) + { + try + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + var cards = await _clientRepository.GetCardsByClientIdAsync(clientId); + return cards; + } + catch + { + throw; + } + } + + public async Task GetCustomerCards(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + Guid customerId = DataExtractor.GetRequiredGuid(data, "customerId"); + + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + var cards = await _clientRepository.GetCardsByCustomerForClientAsync(customerId, clientId); + return cards.Select(c => new + { + jobId = c.JobId, + cardId = c.CardId, + cardNumber = c.CardNumber, + cardType = c.CardType, + nickName = c.NickName, + isPrimary = c.IsPrimary, + consentGiven = c.ConsentGiven + }); + } + + public async Task GetLockPolicy(HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + var client = await _clientRepository.GetClientEntityByIdAsync(clientId) + ?? throw new KeyNotFoundException("Client not found"); + + return new { lockAfterDaysOverdue = client.LockAfterDaysOverdue }; + } + + public async Task UpdateLockPolicy(int? lockAfterDaysOverdue, HttpContext httpContext) + { + if (lockAfterDaysOverdue is < 0) + throw new ArgumentException("lockAfterDaysOverdue cannot be negative."); + + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + var client = await _clientRepository.GetClientEntityByIdAsync(clientId) + ?? throw new KeyNotFoundException("Client not found"); + + client.LockAfterDaysOverdue = lockAfterDaysOverdue; + await _uow.SaveChangesAsync(); + + return new { lockAfterDaysOverdue = client.LockAfterDaysOverdue }; + } + } +} diff --git a/Services/ClientStatService.cs b/Services/ClientStatService.cs new file mode 100644 index 0000000..0169152 --- /dev/null +++ b/Services/ClientStatService.cs @@ -0,0 +1,149 @@ +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; + +namespace Fitness_core_BE.Services +{ + public class ClientStatService : IClientStatService + { + // Muscle Beast Gym (gymos_musslbeast) -- the only gym that gets the "Today's Payments" + // stat, per their explicit request. Not a general feature-flag system; if another gym + // wants this later, promote it to a real per-client setting instead of adding more IDs here. + private static readonly Guid MuscleBeastClientId = Guid.Parse("b2abf7b3-a9f0-11f1-945a-54e8d4bf1eca"); + + private readonly IClientStatRepository _clientStatRepository; + private readonly IOverdueLockRepository _overdueLockRepository; + private readonly UserRoleHelper _userRoleHelper; + + public ClientStatService( + IClientStatRepository clientStatRepository, + IOverdueLockRepository overdueLockRepository, + UserRoleHelper userRoleHelper) + { + _clientStatRepository = clientStatRepository; + _overdueLockRepository = overdueLockRepository; + _userRoleHelper = userRoleHelper; + } + + public async Task GetTotalCustomerCount(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + var count = await _clientStatRepository.GetTotalCustomerCountAsync(clientId); + return new { totalCustomerCount = count }; + } + + public async Task GetMonthlyCustomerCount(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + var count = await _clientStatRepository.GetMonthlyCustomerCountAsync(clientId); + return new { monthlyCustomerCount = count }; + } + + public async Task GetTotalWorkoutPlanCount(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + var count = await _clientStatRepository.GetTotalWorkoutPlanCountAsync(clientId); + return new { totalWorkoutPlanCount = count }; + } + + public async Task GetTotalMealPlanCount(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + var count = await _clientStatRepository.GetTotalMealPlanCountAsync(clientId); + return new { totalMealPlanCount = count }; + } + public async Task GetLastMonthCustomerCount(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + var count = await _clientStatRepository.GetLastMonthCustomerCountAsync(clientId); + return new { lastMonthCustomerCount = count }; + } + + public async Task GetLastFiveAttendances(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + var records = await _clientStatRepository.GetLastFiveAttendancesAsync(clientId); + return records; + } + + public async Task GetLastFivePayments(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + var records = await _clientStatRepository.GetLastFivePaymentsAsync(clientId); + return records; + } + + public async Task GetLastMonthRevenue(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + var revenue = await _clientStatRepository.GetLastMonthRevenueAsync(clientId); + return new { lastMonthRevenue = revenue }; + } + + public async Task GetMonthlyRevenueVariation(object payload, HttpContext httpContext, DateTime? from = null, DateTime? to = null) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + var stats = await _clientStatRepository.GetMonthlyRevenueVariationAsync(clientId, from, to); + + var isOwner = (await _userRoleHelper.GetOwnedClientId(userId)).HasValue; + if (!isOwner) + { + // Revenue is owner-only, enforced here (not just hidden client-side) — staff + // still get real NewMemberCount points, just no Revenue figures. + foreach (var point in stats) point.Revenue = 0m; + } + + return stats; + } + + public async Task GetTotalRevenue(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + var revenue = await _clientStatRepository.GetTotalRevenueAsync(clientId); + return new { totalRevenue = revenue }; + } + + public async Task GetClientStatSummary(object payload, HttpContext httpContext, DateTime? from = null, DateTime? to = null) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + var summary = await _clientStatRepository.GetClientStatSummaryAsync(clientId, from, to); + summary.OverdueCount = (await _overdueLockRepository.GetOverdueCustomerIdsAsync(clientId, DateTime.UtcNow)).Count; + summary.ShowTodayPayments = clientId == MuscleBeastClientId; + + var isOwner = (await _userRoleHelper.GetOwnedClientId(userId)).HasValue; + if (!isOwner) + { + // Revenue is owner-only, enforced here (not just hidden client-side) — a staff + // member calling this endpoint directly still gets zeros, not real figures. + summary.ThisMonthRevenue = 0m; + summary.LastMonthRevenue = 0m; + summary.RevenueGrowthPercentage = 0m; + summary.TotalRevenue = 0m; + } + + return summary; + } + } +} diff --git a/Services/CustomerInvoiceService.cs b/Services/CustomerInvoiceService.cs new file mode 100644 index 0000000..1fb20e1 --- /dev/null +++ b/Services/CustomerInvoiceService.cs @@ -0,0 +1,231 @@ +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using Fitness_core_BE.Infra.UoW; +using System.Text.Json; +using Fitness_core_BE.Helpers; + +namespace Fitness_core_BE.Services.CustomerInvoice +{ + public class CustomerInvoiceService : ICustomerInvoiceService + { + private readonly ICustomerInvoiceRepository _customerInvoiceRepository; + private readonly IPaymentRepository _paymentRepository; + private readonly IUnitOfWork _uow; + private readonly UserRoleHelper _userRoleHelper; + + public CustomerInvoiceService(ICustomerInvoiceRepository customerInvoiceRepository, IPaymentRepository paymentRepository, IUnitOfWork uow, UserRoleHelper userRoleHelper) + { + _customerInvoiceRepository = customerInvoiceRepository; + _paymentRepository = paymentRepository; + _uow = uow; + _userRoleHelper = userRoleHelper; + } + + public async Task CreateCustomerInvoiceFromSubscription(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + if (!data!.ContainsKey("subscriptionId")) + { + throw new KeyNotFoundException("subscriptionId is required"); + } + + var subscriptionId = Guid.Parse(data["subscriptionId"].GetString()!); + var result = await _customerInvoiceRepository.CreateCustomerInvoiceFromSubscription(subscriptionId); + await _uow.CommitAsync(); + + return result; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task GetCustomerInvoiceById(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + + if (!data!.ContainsKey("customerInvoiceId")) + { + throw new KeyNotFoundException("customerInvoiceId is required"); + } + + var customerInvoiceId = Guid.Parse(data["customerInvoiceId"].GetString()!); + var customerInvoice = await _customerInvoiceRepository.GetCustomerInvoiceById(customerInvoiceId); + + if (customerInvoice == null) + { + throw new KeyNotFoundException("CustomerInvoice not found"); + } + + var paid = await _paymentRepository.GetApprovedPaidAmountAsync(customerInvoice.CustomerInvoiceId); + var carriedForward = await _customerInvoiceRepository.GetCarriedForwardAmountsAsync( + new List { customerInvoice.CustomerInvoiceId }); + carriedForward.TryGetValue(customerInvoice.CustomerInvoiceId, out var carriedForwardAmount); + + var link = customerInvoice.CustomerSubscription?.TenantCustomerLink; + return new + { + customerInvoice.CustomerInvoiceId, + customerInvoice.FKCustomerSubscriptionId, + customerInvoice.FKClientId, + customerInvoice.FKUserId, + customerInvoice.Amount, + // The portion of Amount that's a prior cycle's unpaid balance rolled forward + // (vs. this cycle's own charge) -- see SubscriptionRenewalRunner/BuildAndAddInvoice. + CarriedForwardAmount = carriedForwardAmount, + customerInvoice.Status, + customerInvoice.CustomerInvoiceDate, + PaidAmount = paid, + Balance = customerInvoice.Amount - paid, + // Flattened for callers that don't want to reach through + // CustomerSubscription.TenantCustomerLink for basic display fields. + CustomerName = link?.DisplayName, + CustomerPhone = link?.PrimaryPhoneCached, + customerInvoice.CustomerSubscription + }; + } + + public async Task GetCustomerInvoicesByCustomerId(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + + if (!data!.ContainsKey("customerId")) + { + throw new KeyNotFoundException("customerId is required"); + } + + var customerId = Guid.Parse(data["customerId"].GetString()!); + var invoices = await _customerInvoiceRepository.GetCustomerInvoicesByCustomerId(customerId); + + // Same enriched shape as GetCustomerInvoicesByClientId -- this now backs a public + // "view previous invoices" route the frontend renders as MemberInvoice rows. + var result = new List(invoices.Count); + foreach (var invoice in invoices) + { + var paid = await _paymentRepository.GetApprovedPaidAmountAsync(invoice.CustomerInvoiceId); + var link = invoice.CustomerSubscription?.TenantCustomerLink; + result.Add(new + { + invoice.CustomerInvoiceId, + invoice.FKCustomerSubscriptionId, + invoice.FKClientId, + invoice.Amount, + invoice.Status, + invoice.IsActive, + invoice.CustomerInvoiceDate, + PaidAmount = paid, + Balance = invoice.Amount - paid, + CustomerName = link?.DisplayName, + CustomerPhone = link?.PrimaryPhoneCached, + FKCustomerId = invoice.CustomerSubscription?.FKCustomerId, + invoice.CustomerSubscription + }); + } + return result; + } + + public async Task GetCustomerInvoicesByClientId(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + // scope=="latest": one row per customer (their latest approved invoice) instead of + // every invoice they've ever had -- used by the regular invoices tab now that the + // renewal-approval workflow can leave older invoices (Paid/RolledOver/Cancelled) + // sitting alongside the current one. Default (no scope) keeps today's full-history + // behavior for any other existing caller of this endpoint. + var data = DeserializePayload.Deserialize(payload); + var scope = data != null && data.ContainsKey("scope") ? data["scope"].GetString() : null; + + var invoices = scope == "latest" + ? await _customerInvoiceRepository.GetLatestApprovedInvoicePerCustomerAsync(clientId) + : await _customerInvoiceRepository.GetCustomerInvoicesByClientId(clientId); + + // Enrich each invoice with its approved paid total and outstanding balance, + // preserving the nested shape the frontend reads. + var result = new List(invoices.Count); + foreach (var invoice in invoices) + { + var paid = await _paymentRepository.GetApprovedPaidAmountAsync(invoice.CustomerInvoiceId); + var link = invoice.CustomerSubscription?.TenantCustomerLink; + result.Add(new + { + invoice.CustomerInvoiceId, + invoice.FKCustomerSubscriptionId, + invoice.FKClientId, + invoice.Amount, + invoice.Status, + invoice.IsActive, + invoice.CustomerInvoiceDate, + PaidAmount = paid, + Balance = invoice.Amount - paid, + // Flattened for callers that don't want to reach through + // CustomerSubscription.TenantCustomerLink for basic display fields. + CustomerName = link?.DisplayName, + CustomerPhone = link?.PrimaryPhoneCached, + FKCustomerId = invoice.CustomerSubscription?.FKCustomerId, + invoice.CustomerSubscription + }); + } + return result; + } + + public async Task UpdateCustomerInvoiceStatus(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + if (!data!.ContainsKey("customerInvoiceId") || !data.ContainsKey("status")) + { + throw new KeyNotFoundException("customerInvoiceId and status are required"); + } + + var customerInvoiceId = Guid.Parse(data["customerInvoiceId"].GetString()!); + var status = data["status"].GetString()!; + + // Validate status + var validStatuses = new[] { "Pending", "Partial", "Paid", "Failed", "Cancelled", "Refunded", "RolledOver" }; + if (!validStatuses.Contains(status)) + { + throw new InvalidOperationException($"Invalid status. Valid statuses are: {string.Join(", ", validStatuses)}"); + } + + var result = await _customerInvoiceRepository.UpdateCustomerInvoiceStatus(customerInvoiceId, status); + await _uow.CommitAsync(); + + return result; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + + public async Task CalculateCustomerInvoiceTotal(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + + if (!data!.ContainsKey("customerInvoiceId")) + { + throw new KeyNotFoundException("customerInvoiceId is required"); + } + + var customerInvoiceId = Guid.Parse(data["customerInvoiceId"].GetString()!); + var customerInvoice = await _customerInvoiceRepository.GetCustomerInvoiceById(customerInvoiceId); + var total = customerInvoice?.Amount ?? 0; + + return new { Total = total }; + } + } +} diff --git a/Services/CustomerPlanService.cs b/Services/CustomerPlanService.cs new file mode 100644 index 0000000..647067e --- /dev/null +++ b/Services/CustomerPlanService.cs @@ -0,0 +1,440 @@ +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.DTOs.CustomerPlan; +using System.Text.Json; +using Fitness_core_BE.Helpers; + + +namespace Fitness_core_BE.Services.CustomerPlanService +{ + public class CustomerPlanService : ICustomerPlanService + { + private const string ScopeIndividual = "Individual"; + private const string ScopeGroup = "Group"; + private const string ModeRolling = "Rolling"; + private const string ModeFixedRange = "FixedRange"; + private const string RolloverNone = "NoRollover"; + private const string RolloverGrace = "GracePeriod"; + private const string BillingPrepaid = "Prepaid"; + private const string BillingPostpaid = "Postpaid"; + + private readonly ICustomerPlanRepository _customerPlanRepository; + private readonly IPlaceRepo _placeRepo; + private readonly IUnitOfWork _uow; + private readonly UserRoleHelper _userRoleHelper; + + public CustomerPlanService( + ICustomerPlanRepository customerPlanRepository, + IPlaceRepo placeRepo, + IUnitOfWork uow, + UserRoleHelper userRoleHelper) + { + _customerPlanRepository = customerPlanRepository; + _placeRepo = placeRepo; + _uow = uow; + _userRoleHelper = userRoleHelper; + } + + public async Task GetAllCustomerPlans(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + + bool includeInactive = data != null && + data.ContainsKey("includeInactive") && + data["includeInactive"].GetBoolean(); + + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid contextClientId = await _userRoleHelper.GetClientId(userId); + + Guid? requestedClientId = null; + if (data != null && data.ContainsKey("FKClientId") && data["FKClientId"].ValueKind != JsonValueKind.Null) + { + requestedClientId = Guid.Parse(data["FKClientId"].GetString()!); + } + + var clientId = requestedClientId ?? contextClientId; + + var plans = await _customerPlanRepository.GetAllCustomerPlans(includeInactive, clientId); + + var placeIdsByPlan = await _placeRepo.GetPlaceIdsByPlanIdsAsync(plans.Select(p => p.CustomerPlanId).ToList()); + var allPlaceIds = placeIdsByPlan.Values.SelectMany(ids => ids).Distinct().ToList(); + var placeNames = await _placeRepo.GetPlaceNamesByIdsAsync(allPlaceIds); + + return plans.Select(p => + { + var placeIds = placeIdsByPlan.GetValueOrDefault(p.CustomerPlanId) ?? new List(); + return MapToDto(p, p.Client?.ClientName, placeIds, placeNames); + }).ToList(); + } + + public async Task GetCustomerPlanById(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + + if (!data!.ContainsKey("customerPlanId")) + { + throw new KeyNotFoundException("customerPlanId is required"); + } + + var customerPlanId = Guid.Parse(data["customerPlanId"].GetString()!); + var plan = await _customerPlanRepository.GetCustomerPlanById(customerPlanId); + + if (plan == null) + { + throw new KeyNotFoundException("Customer Plan not found"); + } + + return await MapToDtoWithPlacesAsync(plan, plan.Client?.ClientName); + } + + public async Task CreateCustomerPlan(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + if (!data!.ContainsKey("planName")) + { + throw new KeyNotFoundException("planName is required"); + } + + if (!data.ContainsKey("planPrice")) + { + throw new KeyNotFoundException("planPrice is required"); + } + + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + var customerPlan = new Models.CustomerPlan + { + FKClientId = clientId, + PlanName = data["planName"].GetString(), + PlanDescription = data.ContainsKey("planDescription") + ? data["planDescription"].GetString() + : null, + PlanPrice = data["planPrice"].GetDecimal(), + HasOnboardingFee = data.ContainsKey("hasOnboardingFee") && data["hasOnboardingFee"].GetBoolean(), + OnboardingFeeAmount = data.ContainsKey("onboardingFeeAmount") ? data["onboardingFeeAmount"].GetDecimal() : 0m, + PlanScope = data.ContainsKey("planScope") ? data["planScope"].GetString() ?? ScopeIndividual : ScopeIndividual, + MemberLimit = data.ContainsKey("memberLimit") ? data["memberLimit"].GetInt32() : 1, + DurationMode = data.ContainsKey("durationMode") ? data["durationMode"].GetString() ?? ModeRolling : ModeRolling, + DurationInDays = data.ContainsKey("durationInDays") ? data["durationInDays"].GetInt32() : 0, + ValidFrom = DataExtractor.GetOptionalDateTime(data, "validFrom"), + ValidTo = DataExtractor.GetOptionalDateTime(data, "validTo"), + AllowSplitPayments = data.ContainsKey("allowSplitPayments") && data["allowSplitPayments"].GetBoolean(), + InstallmentCount = data.ContainsKey("installmentCount") ? data["installmentCount"].GetInt32() : 0, + SessionsPerCycle = data.ContainsKey("sessionsPerCycle") && data["sessionsPerCycle"].ValueKind != JsonValueKind.Null + ? data["sessionsPerCycle"].GetInt32() + : null, + SessionRolloverPolicy = data.ContainsKey("sessionRolloverPolicy") ? data["sessionRolloverPolicy"].GetString() ?? RolloverNone : RolloverNone, + RolloverGraceDays = data.ContainsKey("rolloverGraceDays") ? data["rolloverGraceDays"].GetInt32() : 0, + BillingTiming = data.ContainsKey("billingTiming") ? data["billingTiming"].GetString() ?? BillingPrepaid : BillingPrepaid + }; + + NormalizeAndValidate(customerPlan); + + var result = await _customerPlanRepository.CreateCustomerPlan(customerPlan); + await _uow.SaveChangesAsync(); + + var placeIds = DataExtractor.GetOptionalGuidList(data, "placeIds"); + if (placeIds != null) + await _placeRepo.SetPlanPlacesAsync(result.CustomerPlanId, placeIds); + + await _uow.CommitAsync(); + + // Fetch the created plan with client details + var createdPlan = await _customerPlanRepository.GetCustomerPlanById(result.CustomerPlanId); + + return await MapToDtoWithPlacesAsync(result, createdPlan?.Client?.ClientName); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task UpdateCustomerPlan(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + if (!data!.ContainsKey("customerPlanId")) + { + throw new KeyNotFoundException("customerPlanId is required"); + } + + var customerPlanId = Guid.Parse(data["customerPlanId"].GetString()!); + + var existingPlan = await _customerPlanRepository.GetCustomerPlanById(customerPlanId); + if (existingPlan == null) + { + throw new KeyNotFoundException("Customer Plan not found"); + } + + // Merge: start from existing values, override only the provided keys, + // then re-validate the combined configuration. + if (data.ContainsKey("planName")) existingPlan.PlanName = data["planName"].GetString(); + if (data.ContainsKey("planDescription")) existingPlan.PlanDescription = data["planDescription"].GetString(); + if (data.ContainsKey("planPrice")) existingPlan.PlanPrice = data["planPrice"].GetDecimal(); + if (data.ContainsKey("hasOnboardingFee")) existingPlan.HasOnboardingFee = data["hasOnboardingFee"].GetBoolean(); + if (data.ContainsKey("onboardingFeeAmount")) existingPlan.OnboardingFeeAmount = data["onboardingFeeAmount"].GetDecimal(); + if (data.ContainsKey("planScope")) existingPlan.PlanScope = data["planScope"].GetString() ?? existingPlan.PlanScope; + if (data.ContainsKey("memberLimit")) existingPlan.MemberLimit = data["memberLimit"].GetInt32(); + if (data.ContainsKey("durationMode")) existingPlan.DurationMode = data["durationMode"].GetString() ?? existingPlan.DurationMode; + if (data.ContainsKey("durationInDays")) existingPlan.DurationInDays = data["durationInDays"].GetInt32(); + if (data.ContainsKey("validFrom")) existingPlan.ValidFrom = DataExtractor.GetOptionalDateTime(data, "validFrom"); + if (data.ContainsKey("validTo")) existingPlan.ValidTo = DataExtractor.GetOptionalDateTime(data, "validTo"); + if (data.ContainsKey("allowSplitPayments")) existingPlan.AllowSplitPayments = data["allowSplitPayments"].GetBoolean(); + if (data.ContainsKey("installmentCount")) existingPlan.InstallmentCount = data["installmentCount"].GetInt32(); + if (data.ContainsKey("sessionsPerCycle")) + existingPlan.SessionsPerCycle = data["sessionsPerCycle"].ValueKind == JsonValueKind.Null ? null : data["sessionsPerCycle"].GetInt32(); + if (data.ContainsKey("sessionRolloverPolicy")) existingPlan.SessionRolloverPolicy = data["sessionRolloverPolicy"].GetString() ?? existingPlan.SessionRolloverPolicy; + if (data.ContainsKey("rolloverGraceDays")) existingPlan.RolloverGraceDays = data["rolloverGraceDays"].GetInt32(); + if (data.ContainsKey("billingTiming")) existingPlan.BillingTiming = data["billingTiming"].GetString() ?? existingPlan.BillingTiming; + if (data.ContainsKey("isActive")) existingPlan.IsActive = data["isActive"].GetBoolean(); + + NormalizeAndValidate(existingPlan); + + var result = await _customerPlanRepository.UpdateCustomerPlan(existingPlan); + + var placeIds = DataExtractor.GetOptionalGuidList(data, "placeIds"); + if (placeIds != null) + await _placeRepo.SetPlanPlacesAsync(result.CustomerPlanId, placeIds); + + await _uow.CommitAsync(); + + var updatedPlan = await _customerPlanRepository.GetCustomerPlanById(result.CustomerPlanId); + + return await MapToDtoWithPlacesAsync(result, updatedPlan?.Client?.ClientName); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task SetCustomerPlanStatus(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + if (!data!.ContainsKey("customerPlanId")) + { + throw new KeyNotFoundException("customerPlanId is required"); + } + + if (!data.ContainsKey("isActive")) + { + throw new KeyNotFoundException("isActive is required"); + } + + var customerPlanId = Guid.Parse(data["customerPlanId"].GetString()!); + var isActive = data["isActive"].GetBoolean(); + + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + // Scoped to the caller's gym, so a plan id from another gym reads as + // "not found" rather than being toggled. + var plan = await _customerPlanRepository.SetCustomerPlanActive(customerPlanId, clientId, isActive); + + if (plan == null) + { + throw new KeyNotFoundException("Customer Plan not found"); + } + + await _uow.CommitAsync(); + + return await MapToDtoWithPlacesAsync(plan, plan.Client?.ClientName); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task DeleteCustomerPlan(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + if (!data!.ContainsKey("customerPlanId")) + { + throw new KeyNotFoundException("customerPlanId is required"); + } + + var customerPlanId = Guid.Parse(data["customerPlanId"].GetString()!); + + // Check if hard delete is requested (default is soft delete) + bool softDelete = !data.ContainsKey("hardDelete") || !data["hardDelete"].GetBoolean(); + + var result = await _customerPlanRepository.DeleteCustomerPlan(customerPlanId, softDelete); + + if (!result) + { + throw new KeyNotFoundException("Customer Plan not found"); + } + + await _uow.CommitAsync(); + + return new + { + success = true, + message = softDelete + ? "Customer Plan deactivated successfully" + : "Customer Plan deleted successfully", + softDelete = softDelete + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + /// + /// Enforces the cross-field rules of the plan configuration and fills + /// derived defaults (member limit, installment count, cleared date range). + /// Throws ArgumentException on invalid combinations. + /// + private static void NormalizeAndValidate(Models.CustomerPlan plan) + { + // Scope + if (plan.PlanScope != ScopeIndividual && plan.PlanScope != ScopeGroup) + throw new ArgumentException($"planScope must be \"{ScopeIndividual}\" or \"{ScopeGroup}\""); + + if (plan.PlanScope == ScopeGroup) + { + if (plan.MemberLimit < 2) + throw new ArgumentException("memberLimit must be at least 2 for Group plans (couple = 2)"); + } + else + { + plan.MemberLimit = 1; + } + + // Duration + if (plan.DurationMode != ModeRolling && plan.DurationMode != ModeFixedRange) + throw new ArgumentException($"durationMode must be \"{ModeRolling}\" or \"{ModeFixedRange}\""); + + int effectiveDays; + if (plan.DurationMode == ModeRolling) + { + if (plan.DurationInDays < 1) + throw new ArgumentException("durationInDays (>= 1) is required for Rolling plans"); + plan.ValidFrom = null; + plan.ValidTo = null; + effectiveDays = plan.DurationInDays; + } + else + { + if (plan.ValidFrom == null || plan.ValidTo == null) + throw new ArgumentException("validFrom and validTo are required for FixedRange plans"); + if (plan.ValidTo <= plan.ValidFrom) + throw new ArgumentException("validTo must be after validFrom"); + plan.DurationInDays = 0; + effectiveDays = (int)(plan.ValidTo.Value - plan.ValidFrom.Value).TotalDays; + } + + if (plan.PlanPrice < 0) + throw new ArgumentException("planPrice must be a positive value"); + + if (plan.OnboardingFeeAmount < 0) + throw new ArgumentException("onboardingFeeAmount must be zero or greater"); + if (!plan.HasOnboardingFee) + plan.OnboardingFeeAmount = 0m; + else if (plan.OnboardingFeeAmount <= 0) + throw new ArgumentException("onboardingFeeAmount must be greater than zero when hasOnboardingFee is true"); + + // Split payments: mostly-equal installments (e.g. Rs. 6000 / 6 months -> 6 x Rs. 1000). + if (plan.AllowSplitPayments) + { + if (plan.InstallmentCount < 2) + { + // Default: one installment per month of the plan's duration. + plan.InstallmentCount = Math.Max(2, (int)Math.Round(effectiveDays / 30.0)); + } + } + else + { + plan.InstallmentCount = 1; + } + + // Session-count / rollover / billing timing (new SRS requirements). + if (plan.SessionRolloverPolicy != RolloverNone && plan.SessionRolloverPolicy != RolloverGrace) + throw new ArgumentException($"sessionRolloverPolicy must be \"{RolloverNone}\" or \"{RolloverGrace}\""); + + if (plan.SessionsPerCycle.HasValue) + { + if (plan.SessionsPerCycle.Value < 1) + throw new ArgumentException("sessionsPerCycle must be at least 1 when set"); + + if (plan.SessionRolloverPolicy == RolloverGrace && plan.RolloverGraceDays < 1) + throw new ArgumentException("rolloverGraceDays must be at least 1 when sessionRolloverPolicy is \"GracePeriod\""); + } + else + { + // No session allowance on this plan (unlimited-access, existing gym behavior) -- + // rollover settings are meaningless without a per-cycle count to roll over. + plan.SessionRolloverPolicy = RolloverNone; + plan.RolloverGraceDays = 0; + } + + if (plan.BillingTiming != BillingPrepaid && plan.BillingTiming != BillingPostpaid) + throw new ArgumentException($"billingTiming must be \"{BillingPrepaid}\" or \"{BillingPostpaid}\""); + } + + private async Task MapToDtoWithPlacesAsync(Models.CustomerPlan plan, string? clientName) + { + var placeIds = await _placeRepo.GetPlaceIdsForPlanAsync(plan.CustomerPlanId); + var placeNames = await _placeRepo.GetPlaceNamesByIdsAsync(placeIds); + return MapToDto(plan, clientName, placeIds, placeNames); + } + + private static CustomerPlanDto MapToDto( + Models.CustomerPlan plan, + string? clientName, + List placeIds, + Dictionary placeNames) => new() + { + CustomerPlanId = plan.CustomerPlanId, + FKClientId = plan.FKClientId, + ClientName = clientName, + PlanName = plan.PlanName, + PlanDescription = plan.PlanDescription, + PlanPrice = plan.PlanPrice, + HasOnboardingFee = plan.HasOnboardingFee, + OnboardingFeeAmount = plan.OnboardingFeeAmount, + DurationInDays = plan.DurationInDays, + PlanScope = plan.PlanScope, + MemberLimit = plan.MemberLimit, + DurationMode = plan.DurationMode, + ValidFrom = plan.ValidFrom, + ValidTo = plan.ValidTo, + AllowSplitPayments = plan.AllowSplitPayments, + InstallmentCount = plan.InstallmentCount, + SessionsPerCycle = plan.SessionsPerCycle, + SessionRolloverPolicy = plan.SessionRolloverPolicy, + RolloverGraceDays = plan.RolloverGraceDays, + BillingTiming = plan.BillingTiming, + IsActive = plan.IsActive, + CreatedAt = plan.CreatedAt, + PlaceIds = placeIds, + PlaceNames = placeIds.Select(id => placeNames.GetValueOrDefault(id, "")).Where(n => n != "").ToList() + }; + } +} diff --git a/Services/CustomerService.cs b/Services/CustomerService.cs new file mode 100644 index 0000000..0a6bfc6 --- /dev/null +++ b/Services/CustomerService.cs @@ -0,0 +1,1638 @@ +using Fitness_core_BE.DTOs.Auth; +using Fitness_core_BE.Domain; +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using System.Text.Json; +using System.Globalization; +using Fitness_core_BE.Models.IOs; +using QuestPDF.Fluent; +using QuestPDF.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Services.CustomerService +{ + public class CustomerService : ICustomerService + { + private readonly ICustomerRepository _customerRepository; + private readonly IUnitOfWork _uow; + private readonly IAuthService _authService; + private readonly IWorkoutRepository _workoutRepository; + private readonly IMealRepository _mealRepository; + private readonly ICustomerSubscriptionRepository _customerSubscriptionRepository; + private readonly ICustomerInvoiceRepository _customerInvoiceRepository; + private readonly UserRoleHelper _userRoleHelper; + private readonly IClientRepository _clientRepository; + private readonly IFeedService _feedService; + private readonly IAccessDeviceService _accessDeviceService; + private readonly IAccessDeviceRepo _accessDeviceRepo; + private readonly ITermsRepository _termsRepository; + private readonly ICustomerPlanRepository _customerPlanRepository; + // Customer/UserDocuments are central-bucket; CustomerSubscriptions/CustomerInvoices + // (used directly below, not through a repository) are tenant-bucket. + private readonly CentralDbContext _dbContext; + private readonly TenantDbContext _tenantDbContext; + private readonly ICustomerNotificationRepository _customerNotificationRepository; + + public CustomerService( + ICustomerRepository customerRepository, + IUnitOfWork uow, + IAuthService authService, + IFeedService feedService, + IWorkoutRepository workoutRepository, + IMealRepository mealRepository, + ICustomerSubscriptionRepository customerSubscriptionRepository, + ICustomerInvoiceRepository customerInvoiceRepository, + UserRoleHelper userRoleHelper, + IClientRepository clientRepository, + IAccessDeviceService accessDeviceService, + IAccessDeviceRepo accessDeviceRepo, + ITermsRepository termsRepository, + ICustomerPlanRepository customerPlanRepository, + CentralDbContext dbContext, + TenantDbContext tenantDbContext, + ICustomerNotificationRepository customerNotificationRepository) + { + _customerRepository = customerRepository; + _uow = uow; + _authService = authService; + _feedService = feedService; + _workoutRepository = workoutRepository; + _mealRepository = mealRepository; + _customerSubscriptionRepository = customerSubscriptionRepository; + _customerInvoiceRepository = customerInvoiceRepository; + _userRoleHelper = userRoleHelper; + _clientRepository = clientRepository; + _accessDeviceService = accessDeviceService; + _accessDeviceRepo = accessDeviceRepo; + _termsRepository = termsRepository; + _customerPlanRepository = customerPlanRepository; + _dbContext = dbContext; + _tenantDbContext = tenantDbContext; + _customerNotificationRepository = customerNotificationRepository; + } + + + + // Pre-OTP login check. Anonymous — given a phone/email, reports whether it belongs to + // an existing customer (active or not — an inactive identity still counts as "already + // has an account") and whether they've already set a fitness goal (so the app can send + // a client-created customer through goal-setting on their first login). + public async Task CustomerExists(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + var identifier = DataExtractor.GetRequiredString(data, "identifier"); + + var customer = await _customerRepository.GetCustomerByIdentifier(identifier); + if (customer == null) + return new { exists = false, hasGoal = false }; + + var latestTarget = await _customerRepository.GetLatestCustomerTargetAsync(customer.CusId); + return new { exists = true, hasGoal = latestTarget != null }; + } + + // Verification gate helper (used by the mobile controller to block actionable + // features until the customer verifies email or mobile). + public async Task IsCustomerVerified(HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + return await _customerRepository.IsverifiedCus(cusId); + } + + // V1 implementation + public async Task CreateCustomer(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + var user = new User + { + UserName = DataExtractor.GetRequiredString(data, "fullname"), + PhoneNumber = DataExtractor.GetRequiredString(data, "mobileNumber"), + IsActive = true, + CreateDate = DateTime.UtcNow + }; + + var customer = new Customer + { + CusName = DataExtractor.GetRequiredString(data, "fullname"), + // The device ID ("deviceName") and the real Expo push token ("pushTokenId") + // are two different values the app sends — this column is for the push + // token, which is what PushNotificationService needs to actually send pushes. + PushTokenId = DataExtractor.GetOptionalStringByKeys(data, "pushTokenId", "PushTokenId"), + Gender = DataExtractor.GetRequiredString(data, "gender"), + BoD = DataExtractor.GetRequiredDateOfBirth(data, "bod"), + PrimaryPH = DataExtractor.GetRequiredString(data, "mobileNumber"), + IsPrimaryPHVerfied = false, + User = user + }; + + var result = await _customerRepository.CreateCustomer(customer); + + await _uow.SaveChangesAsync(); + + // Anchor row for this tenant's customer FKs -- without it the first + // tenant-side write for this customer (subscription, card, attendance) + // fails the FK constraint. + await _customerRepository.EnsureTenantCustomerLink(result); + await _uow.SaveChangesAsync(); + + AuthResponse authResponse; + try + { + var authPayload = DeserializePayload.Deserialize(payload); + authPayload["userId"] = JsonSerializer.SerializeToElement(user.UserId); + authResponse = await _authService.RegisterUser(authPayload, httpContext); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Auth registration failed: {ex.Message}"); + } + + if (authResponse.StatusCode != 200 || !authResponse.Success) + { + throw new InvalidOperationException($"Auth registration failed: {authResponse.Message}"); + } + + await _uow.CommitAsync(); + + return new + { + customer = result, + auth = authResponse + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + + // V1 implementation + public async Task CreateCusProgressRec(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + Guid uId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(uId); + var customer = await _customerRepository.GetCustomerById(cusId) + ?? throw new NotFoundException("Customer not found"); + + decimal? targetWeight = DataExtractor.GetOptionalDecimalByKeys(data, "targetWeight"); + decimal? timePeriodMonths = DataExtractor.GetOptionalDecimalByKeys(data, "timePeriod"); + if (timePeriodMonths == null) + throw new ArgumentException("Field 'timePeriod' is required."); + + var createdAt = DateTime.UtcNow; + var currentWeight = data.ContainsKey("currentWeight") + ? DataExtractor.GetRequiredDecimal(data, "currentWeight") + : DataExtractor.GetRequiredDecimal(data, "Weight"); + var currentHeight = data.ContainsKey("currentHeight") + ? DataExtractor.GetRequiredDecimal(data, "currentHeight") + : DataExtractor.GetRequiredDecimal(data, "Height"); + var goal = DataExtractor.GetRequiredStringByKeys(data, "goal"); + if (targetWeight == null && !CustomerProgressHelper.IsMaintainGoal(goal)) + throw new ArgumentException("Field 'targetWeight' is required."); + + var targetWeightValue = targetWeight ?? currentWeight; + var age = CustomerProgressHelper.GetProgressPredictionAge(data, customer, createdAt); + var gender = CustomerProgressHelper.GetProgressPredictionGender(data, customer); + var activityLevel = DataExtractor.GetRequiredStringByKeys(data, "activityLevel"); + + var predictions = CustomerProgressHelper.BuildProgressPredictions( + currentWeight, + targetWeightValue, + currentHeight, + goal, + timePeriodMonths.Value, + age, + gender, + activityLevel); + + + var latestTarget = await _customerRepository.GetLatestCustomerTargetAsync(cusId); + var customerTarget = CustomerProgressHelper.BuildCustomerTarget( + cusId, + currentWeight, + currentHeight, + targetWeightValue, + goal, + timePeriodMonths.Value, + predictions, + latestTarget, + createdAt); + + await _uow.BeginAsync(); + try + { + var createdTarget = await _customerRepository.CreateCustomerTargetAsync(customerTarget); + if (createdTarget == null) + throw new Exception("Something went wrong"); + await _uow.SaveChangesAsync(); + await _customerRepository.UpdateWeeklyProgressAsync( + cusId, + customerTarget.CustomerTargetId, + currentWeight, + createdAt); + await _uow.CommitAsync(); + return new { message = "Progress record saved", customerTargetId = customerTarget.CustomerTargetId }; + } + catch (Exception ex) + { + await _uow.RollbackAsync(); + throw new Exception("Progress record saving failed"); + } + } + + // V1 implementation + public async Task GetCustomerProgress(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + + var groupedProgress = await _customerRepository.GetCustomerProgressGroupedByGoalAsync(cusId); + + if (groupedProgress == null || !groupedProgress.Any()) + { + return new { message = "No progress records found" }; + } + + var result = groupedProgress.ToDictionary( + group => group.Key, + group => group.Value + .Select(CustomerProgressHelper.BuildCustomerProgressResponse) + .ToList()); + + return result; + } + + //V1 implementation + public async Task GetWeeklyProgressForCustomer(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + Guid customerTargetId =DataExtractor.GetRequiredGuid(data, "customerTargetId"); + + return await _customerRepository.GetWeeklyProgressForCustomerAsync(cusId, customerTargetId); + } + + + //V1 implementation + public async Task UpdateCusProgressRec(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + Guid uId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(uId); + + var height = DataExtractor.GetOptionalDecimalByKeys( + data, + "height", + "Height", + "currentHeight", + "CurrentHeight"); + var weight = DataExtractor.GetOptionalDecimalByKeys( + data, + "weight", + "Weight", + "currentWeight", + "CurrentWeight"); + + if (height == null && weight == null) + throw new ArgumentException("At least one of 'height' or 'weight' is required."); + if (height <= 0) + throw new ArgumentException("Field 'height' must be greater than zero."); + if (weight <= 0) + throw new ArgumentException("Field 'weight' must be greater than zero."); + + DateTime? progressDate = null; + if (data.ContainsKey("progressDate")) + progressDate = DataExtractor.GetOptionalDateTime(data, "progressDate"); + else if (data.ContainsKey("ProgressDate")) + progressDate = DataExtractor.GetOptionalDateTime(data, "ProgressDate"); + + await _uow.BeginAsync(); + try + { + var updatedTarget = await _customerRepository.GetLatestCustomerTargetAsync(cusId) + ?? throw new NotFoundException("Customer target not found"); + + var isChanged = + (height.HasValue && updatedTarget.CurrentHeight != height.Value) || + (weight.HasValue && updatedTarget.CurrentWeight != weight.Value); + + if (height.HasValue) + updatedTarget.CurrentHeight = height.Value; + + if (weight.HasValue) + { + updatedTarget.CurrentWeight = weight.Value; + await _customerRepository.UpdateWeeklyProgressAsync( + cusId, + updatedTarget.CustomerTargetId, + weight.Value, + progressDate); + + // Re-project the goal trajectory from this new weight to the (unchanged) + // target, over whatever's left of the original plan window — best-effort, + // must never break the core weight-update if the target's data is unusual. + try + { + if (updatedTarget.TargetWeight.HasValue && + decimal.TryParse(updatedTarget.TimePeriod, NumberStyles.Any, CultureInfo.InvariantCulture, out var totalMonths)) + { + var totalWeeksAvailable = (double)totalMonths * 4.33; + var elapsedWeeks = Math.Max(0.0, (DateTime.UtcNow - updatedTarget.CreatedAt).TotalDays / 7.0); + var remainingWeeks = Math.Max(1.0, totalWeeksAvailable - elapsedWeeks); + + var rebuiltWeights = CustomerProgressHelper.RebuildPredictedWeights( + weight.Value, + updatedTarget.TargetWeight.Value, + updatedTarget.Goal, + remainingWeeks); + updatedTarget.PredictedWeightsJson = JsonSerializer.Serialize(rebuiltWeights); + } + } + catch + { + // Leave the existing PredictedWeightsJson in place if re-projection fails. + } + } + + updatedTarget.IsChanged = updatedTarget.IsChanged || isChanged; + await _uow.CommitAsync(); + + return new + { + message = "Measurements updated", + customerTargetId = updatedTarget.CustomerTargetId, + currentHeight = updatedTarget.CurrentHeight, + currentWeight = updatedTarget.CurrentWeight, + isChanged = updatedTarget.IsChanged + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + + + public async Task AssignPlansToCustomer(Guid customerId, object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + if (data!.ContainsKey("workoutPlanIds") && data["workoutPlanIds"].ValueKind == JsonValueKind.Array) + { + var workoutPlanIds = data["workoutPlanIds"].EnumerateArray() + .Select(x => Guid.Parse(x.GetString()!)) + .ToList(); + + foreach (var workoutPlanId in workoutPlanIds) + { + var customerWorkout = new CustomerWorkout + { + FKCustomerId = customerId, + FKWorkoutPlanId = workoutPlanId, + AssignedDate = DateTime.UtcNow, + IsActive = true + }; + await _workoutRepository.AssignWorkoutToCustomerAsync(customerWorkout); + } + } + + if (data.ContainsKey("mealPlanIds") && data["mealPlanIds"].ValueKind == JsonValueKind.Array) + { + var mealPlanIds = data["mealPlanIds"].EnumerateArray() + .Select(x => Guid.Parse(x.GetString()!)) + .ToList(); + + foreach (var mealPlanId in mealPlanIds) + { + var customerMeal = new Models.CustomerMeal + { + FKCustomerId = customerId, + FKMealPlanId = mealPlanId, + AssignedDate = DateTime.UtcNow, + IsActive = true + }; + await _mealRepository.AssignMealToCustomerAsync(customerMeal); + } + } + + Guid? subscriptionId = null; + Guid? invoiceId = null; + decimal? invoiceAmount = null; + bool alreadyAssigned = false; + // The controller always sends the customerPlanId key (null when the + // member is created without a plan — e.g. a group secondary added later). + if (data.ContainsKey("customerPlanId") && data["customerPlanId"].ValueKind != JsonValueKind.Null) + { + // Guard against duplicate active subscriptions at this gym: callers like + // the create-existing-customer and edit-customer flows submit whatever plan + // is currently shown on the form, not necessarily a deliberate "assign a new + // plan" action, so re-submitting an already-assigned plan must not stack a + // second active subscription. Assigning a genuinely different plan belongs to + // ChangeMembershipPlan, which explicitly deactivates the old one first. + var existingActive = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(customerId); + if (existingActive != null) + { + alreadyAssigned = true; + subscriptionId = existingActive.SubscriptionId; + var existingInvoice = await _customerInvoiceRepository.GetInvoiceBySubscriptionIdAsync(existingActive.SubscriptionId); + invoiceId = existingInvoice?.CustomerInvoiceId; + invoiceAmount = existingInvoice?.Amount; + } + else + { + // Not primary anywhere (checked above), but could still be an active + // secondary on someone else's group plan -- GetActiveSubscriptionByCustomerId + // can't see that (it only matches CustomerSubscription.FKCustomerId, the + // primary). One customer, one plan at a time either way. + var hasOtherActivePlan = await _customerSubscriptionRepository.HasAnyActiveSubscriptionAsync(customerId); + if (hasOtherActivePlan) + throw new ArgumentException("This customer already has an active plan. Remove them from it before assigning a new one."); + + var customerPlanId = Guid.Parse(data["customerPlanId"].GetString()!); + var onboardDate = DataExtractor.GetOptionalDateTime(data, "onboardDate"); + var chargePayment = DataExtractor.GetOptionalBool(data, "chargePayment") ?? true; + + var subscription = new Models.CustomerSubscription + { + FKCustomerPlanId = customerPlanId, + FKCustomerId = customerId, + OnboardDate = onboardDate ?? DateTime.UtcNow, + ExpiryDate = DataExtractor.GetOptionalDateTime(data, "expiryDate"), + IsActive = true, + DiscountType = DataExtractor.GetOptionalString(data, "discountType"), + DiscountValue = DataExtractor.GetOptionalDecimal(data, "discountValue") + }; + + var initialSubItem = new Models.CustomerSubItem + { + SubItemName = data.ContainsKey("subItemName") ? data["subItemName"].GetString() : "Initial Subscription", + SubItemDescription = data.ContainsKey("subItemDescription") ? data["subItemDescription"].GetString() : null, + SubItemPrice = data.ContainsKey("subItemPrice") ? data["subItemPrice"].GetDecimal() : 0, + IsActive = true + }; + + var plan = await _customerPlanRepository.GetCustomerPlanById(customerPlanId) + ?? throw new NotFoundException("Customer plan not found."); + var aggregate = CustomerSubscriptionFactory.Create( + subscription, plan, initialSubItem); + await _customerSubscriptionRepository.AddCustomerSubscriptionAsync( + aggregate.Subscription, + aggregate.InitialSubItem, + aggregate.PrimaryMember, + aggregate.Installments); + subscriptionId = subscription.SubscriptionId; + + // Persist the subscription first: invoice creation re-queries it from + // the DB and needs the invoice from day one for balance tracking. + await _uow.SaveChangesAsync(); + var invoice = await _customerInvoiceRepository.CreateCustomerInvoiceFromSubscription( + subscription.SubscriptionId, chargePayment); + invoiceId = invoice.CustomerInvoiceId; + invoiceAmount = invoice.Amount; + } + } + + await _uow.CommitAsync(); + + return new + { + success = true, + message = alreadyAssigned + ? "Customer already has an active plan at this gym; no new subscription created." + : "Plans assigned successfully to customer", + subscriptionId, + invoiceId, + invoiceAmount + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task UpdateCustomer(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + //if (!data!.ContainsKey("customerId")) + //{ + // throw new KeyNotFoundException("customerId is required"); + //} + + var userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid customerId = await _userRoleHelper.GetCusId(userId); + + Customer oldCus = await _customerRepository.GetCustomerById(customerId); + + if (oldCus == null) + throw new NotFoundException("customer Not Found"); + + // Partial update: the mobile profile lets the customer edit one section (or one + // field) at a time, so only overwrite fields that were actually supplied and + // preserve everything else. GetOptionalString returns null for absent/empty. + oldCus.CusName = DataExtractor.GetOptionalString(data, "fullname") ?? oldCus.CusName; + oldCus.Gender = DataExtractor.GetOptionalString(data, "gender") ?? oldCus.Gender; + oldCus.PrimaryPH = DataExtractor.GetOptionalString(data, "primaryPH") ?? oldCus.PrimaryPH; + oldCus.SecondaryPH = DataExtractor.GetOptionalString(data, "secondaryPH") ?? oldCus.SecondaryPH; + oldCus.NIC = DataExtractor.GetOptionalString(data, "NIC") ?? oldCus.NIC; + oldCus.Address = DataExtractor.GetOptionalString(data, "address") ?? oldCus.Address; + oldCus.Email = DataExtractor.GetOptionalString(data, "email") ?? oldCus.Email; + + if (data.ContainsKey("bod") && data["bod"].ValueKind != JsonValueKind.Null) + oldCus.BoD = DataExtractor.GetRequiredDateOfBirth(data, "bod"); + + var result = await _customerRepository.UpdateCustomer(customerId, oldCus); + if (result == null) + { + throw new KeyNotFoundException("Customer not found"); + } + + await _uow.CommitAsync(); + + return result; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task DeleteCustomer(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + if (!data!.ContainsKey("customerId")) + { + throw new KeyNotFoundException("customerId is required"); + } + + var customerId = Guid.Parse(data["customerId"].GetString()!); + + // TODO: Remove static ID after testing + // var customerId = Guid.Parse("d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f9a"); + + var result = await _customerRepository.DeleteCustomer(customerId); + + if (!result) + { + throw new KeyNotFoundException("Customer not found"); + } + + await _uow.CommitAsync(); + + return new { success = true, message = "Customer deleted successfully" }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task GetCustomerMealPlan(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + + Guid? ActiveClientId = await _customerRepository.GetCustomerClientId(cusId); + + if (ActiveClientId == null) + { + MealPlan? clientMeal = await _customerRepository.GetActiveMealPlanAsync(ActiveClientId, cusId); + if (clientMeal == null) + return new { message = "No active meal plan found for you" }; + + return clientMeal; + } + else + { + MealPlan? cusMeal = await _customerRepository.GetActiveMealPlanAsync(null, cusId); + if (cusMeal == null) + return new { message = "No active meal plan found for you" }; + + return cusMeal; + } + } + + public async Task GetCustomerMealPlanById(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + var mealPlanId = DataExtractor.GetRequiredGuid(data, "mealPlanId"); + + var mealPlan = await _customerRepository.GetMealPlanByIdForCustomerAsync(mealPlanId); + + if (mealPlan == null) + throw new KeyNotFoundException($"Meal plan with ID {mealPlanId} not found"); + + if (mealPlan.MealItems != null) + { + foreach (var item in mealPlan.MealItems) + { + var itemAsString = item.MealItems as string; + if (!string.IsNullOrEmpty(itemAsString)) + { + try + { + item.MealItems = System.Text.Json.JsonSerializer.Deserialize(itemAsString); + } + catch + { + // Keep as string if parsing fails + } + } + } + } + + return mealPlan; + } + + public async Task GetFollowedMealPlans(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + + var mealPlans = await _customerRepository.GetFollowedMealPlansByCustomerIdAsync(cusId); + + foreach (var mealPlan in mealPlans) + { + if (mealPlan.MealItems != null) + { + foreach (var item in mealPlan.MealItems) + { + var itemAsString = item.MealItems as string; + if (!string.IsNullOrEmpty(itemAsString)) + { + try + { + item.MealItems = System.Text.Json.JsonSerializer.Deserialize(itemAsString); + } + catch + { + // Keep as string if parsing fails + } + } + } + } + } + + return mealPlans; + } + + public async Task GetCustomerWorkOutPlan(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + + Guid? ActiveClientId = await _customerRepository.GetCustomerClientId(cusId); + + if (ActiveClientId == null) + { + WorkoutPlan? clientWorkOut = await _customerRepository.GetActiveWorkOutAsync(ActiveClientId, cusId); + if (clientWorkOut == null) + return new { message = "No active WorkOut plan found for you" }; + + return clientWorkOut; + } + else + { + WorkoutPlan? cusWorkOut = await _customerRepository.GetActiveWorkOutAsync(null, cusId); + if (cusWorkOut == null) + return new { message = "No active WorkOut plan found for you" }; + + return cusWorkOut; + } + } + + public async Task GetCustomerWorkoutPlanById(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + var workoutPlanId = DataExtractor.GetRequiredGuid(data, "workoutPlanId"); + + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + + var workoutPlan = await _customerRepository.GetWorkoutPlanByIdForCustomerAsync(workoutPlanId, cusId); + + if (workoutPlan == null) + throw new KeyNotFoundException($"Workout plan with ID {workoutPlanId} not found"); + + return new + { + workoutPlan.WorkoutPlanId, + workoutPlan.Name, + workoutPlan.Description, + workoutPlan.IsTemplate, + workoutPlan.WorkoutCategoryId, + workoutPlan.WorkoutCategoryName, + workoutPlan.ClientId, + workoutPlan.ClientName, + workoutPlan.IsActive, + workoutPlan.CreateDate, + WorkoutItems = workoutPlan.WorkoutItems, + AssignedCustomers = workoutPlan.AssignedCustomers + }; + } + + // All of the customer's own AI-generated meal plans (not just the currently-followed + // one) — backs the mobile "AI Plans" tab, distinct from the templates tab. + public async Task GetMyMealPlans(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + return await _customerRepository.GetMyMealPlansByCustomerIdAsync(cusId); + } + + public async Task GetFollowedWorkoutPlans(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + + var workoutPlans = await _customerRepository.GetFollowedWorkoutPlansByCustomerIdAsync(cusId); + + var transformedPlans = workoutPlans.Select(plan => new + { + plan.WorkoutPlanId, + plan.Name, + plan.Description, + plan.IsTemplate, + plan.WorkoutCategoryId, + plan.WorkoutCategoryName, + plan.ClientId, + plan.ClientName, + plan.IsActive, + plan.CreateDate, + WorkoutItems = plan.WorkoutItems, + AssignedCustomers = plan.AssignedCustomers + }).ToList(); + + return transformedPlans; + } + + // All of the customer's own AI-generated workout plans — backs the mobile "AI Plans" + // tab, distinct from the templates tab. + public async Task GetMyWorkoutPlans(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + return await _customerRepository.GetMyWorkoutPlansByCustomerIdAsync(cusId); + } + + + + + + public async Task GetClientDataById(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + Guid clientBizID = DataExtractor.GetRequiredGuid(data, "clientBizID"); + var biz = (Models.ClientBusiness)await _customerRepository.GetClientBizDataAsync(clientBizID); + + // Facilities may hold either the newer structured JSON (AC + named/counted + // equipment) or older plain comma-separated text — GymFacilities.Parse handles + // both, so the mobile gym-details screen can render real equipment instead of + // guessing by splitting on commas. + var facilities = Helpers.GymFacilities.Parse(biz.Facilities); + + return new + { + biz.Bid, + biz.FKClientId, + biz.BusinessType, + biz.BusinessName, + biz.BusinessLogoId, + biz.Description, + biz.IsPysicalLocation, + biz.Address, + biz.City, + biz.Facilities, + hasAc = facilities.HasAc, + equipment = facilities.Equipment, + biz.CoachingMode, + biz.Expertise, + biz.Gender, + biz.ExperienceYears, + biz.SessionType, + biz.StoreType, + biz.ProductCategory, + biz.DeliveryAvailable, + biz.Latitude, + biz.Longitude, + biz.IsActive, + Client = biz.Client + }; + } + + public async Task GetClientGyms(object payload, HttpContext httpContext) + { + var gyms = await _clientRepository.GetActiveGymsAsync(); + + if (gyms == null || !gyms.Any()) + throw new KeyNotFoundException("No gyms found"); + + //var result = gyms.Select(g => new ClientGymResponse + //{ + // Id = g.Bid, + // Name = g.BusinessName, + // Latitude = g.Latitude, + // Longitude = g.Longitude, + // IsActive = g.IsActive + //}).ToList(); + + return gyms; + } + + // QR gym onboarding step 1: list the scanned gym's membership plans so the customer + // can pick one before joining. + public async Task GetGymPlans(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + Guid clientId = DataExtractor.GetRequiredGuid(data, "clientId"); + return await _clientRepository.GetCustomerPlansByClientIdAsync(clientId); + } + + // QR gym onboarding step 2: create the subscription linking this customer to the + // scanned gym via the chosen plan — same association path (Customer -> CustomerSubscription + // -> CustomerPlan -> Client) that client-initiated member creation already uses. + public async Task JoinGym(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + Guid customerPlanId = DataExtractor.GetRequiredGuid(data, "customerPlanId"); + + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + + return await AssignPlansToCustomer(cusId, new { customerPlanId }, httpContext); + } + + public async Task GetGymTerms(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + Guid clientId = DataExtractor.GetRequiredGuid(data, "clientId"); + var terms = await _termsRepository.GetActiveByClientIdAsync(clientId); + if (terms == null) + return new { hasTerms = false }; + + return new + { + hasTerms = true, + terms.Version, + terms.Content, + terms.CreatedAt + }; + } + + public async Task AcceptGymTerms(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + Guid clientId = DataExtractor.GetRequiredGuid(data, "clientId"); + int version = DataExtractor.GetRequiredInt(data, "version"); + + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + + var existing = await _termsRepository.GetAcceptanceAsync(cusId, clientId, version); + if (existing == null) + { + await _termsRepository.AddAcceptanceAsync(new CustomerTermsAcceptance + { + CustomerTermsAcceptanceId = Guid.NewGuid(), + FKCusId = cusId, + FKClientId = clientId, + Version = version, + AcceptedAt = DateTime.UtcNow + }); + await _uow.SaveChangesAsync(); + } + + return new { success = true }; + } + + // Renders the gym's active Terms & Conditions as a downloadable PDF (same QuestPDF + // approach as ReceiptService.GeneratePdf). Returns null when the gym has none published. + public async Task GetGymTermsPdf(Guid clientId) + { + var terms = await _termsRepository.GetActiveByClientIdAsync(clientId); + if (terms == null) + return null; + + var document = QuestPDF.Fluent.Document.Create(container => + { + container.Page(page => + { + page.Size(QuestPDF.Helpers.PageSizes.A4); + page.Margin(36); + page.DefaultTextStyle(x => x.FontSize(11).FontColor(QuestPDF.Helpers.Colors.Grey.Darken3)); + + page.Header().Column(col => + { + col.Item().Text("Terms & Conditions").FontSize(18).Bold(); + col.Item().Text($"Version {terms.Version} — {terms.CreatedAt:yyyy-MM-dd}").FontSize(9).FontColor(QuestPDF.Helpers.Colors.Grey.Medium); + col.Item().PaddingTop(6).LineHorizontal(1).LineColor(QuestPDF.Helpers.Colors.Grey.Lighten2); + }); + + page.Content().PaddingVertical(12).Text(terms.Content); + }); + }); + + return document.GeneratePdf(); + } + + public async Task CreateCusHealthRec(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + Guid uId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(uId); + + CustomerHealth customerHealth = new CustomerHealth + { + BloodGroup = DataExtractor.GetRequiredString(data, "bloodGroup"), + Disabilities = DataExtractor.GetRequiredString(data, "disabilities"), + FKCusId = cusId, + IsActive = true + }; + + await _uow.BeginAsync(); + + try + { + var customerhelth = await _customerRepository.CreateCusHealthRecAsync(customerHealth); + if (customerhelth == null) + throw new Exception("Something went wrong"); + await _uow.CommitAsync(); + return new { message = "Health record saved" }; + } + catch (Exception ex) + { + await _uow.RollbackAsync(); + throw new Exception("Health record saving failed"); + } + } + + public async Task UpdateCusHealthRec(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + Guid uId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(uId); + + CustomerHealth oldCusHealth = await _customerRepository.GetCusHealthRec(cusId); + + if(oldCusHealth == null) + { + var response = await CreateCusHealthRec(payload, httpContext); + return response; + } + + + oldCusHealth.BloodGroup = DataExtractor.GetRequiredString(data, "bloodGroup"); + oldCusHealth.Disabilities = DataExtractor.GetRequiredString(data, "disabilities"); + oldCusHealth.FKCusId = cusId; + + await _uow.BeginAsync(); + + try + { + var customerhelth = await _customerRepository.UpdateCusHealthRecAsync(oldCusHealth); + if (customerhelth == null) + throw new Exception("Something went wrong"); + await _uow.CommitAsync(); + return new { message = "Health record Updated" }; + } + catch (Exception ex) + { + await _uow.RollbackAsync(); + throw new Exception("Health record Updating failed"); + } + } + + + + + public async Task UpdateCustomerTargetNutritions(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + + var predictedNutritionsJson = CustomerProgressHelper.GetPredictedNutritionsJson(data); + + await _uow.BeginAsync(); + try + { + var updatedTarget = await _customerRepository.UpdateLatestCustomerTargetNutritionsAsync( + cusId, + predictedNutritionsJson); + + await _uow.CommitAsync(); + + return new + { + message = "Target nutritions updated", + customerTargetId = updatedTarget.CustomerTargetId, + isChanged = updatedTarget.IsChanged, + predictedNutritions = DeserializePayload.DeserializeJson>(updatedTarget.PredictedNutritionsJson) + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task UpdateWeeklyProgress(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + + Guid customerTargetId = data.ContainsKey("customerTargetId") + ? DataExtractor.GetRequiredGuid(data, "customerTargetId") + : data.ContainsKey("targetId") + ? DataExtractor.GetRequiredGuid(data, "targetId") + : DataExtractor.GetRequiredGuid(data, "progressId"); + decimal weight = DataExtractor.GetRequiredDecimal(data, "weight"); + DateTime? recordedAt = DataExtractor.GetOptionalDateTime(data, "recordedAt"); + + await _uow.BeginAsync(); + try + { + var weeklyProgress = await _customerRepository.UpdateWeeklyProgressAsync(cusId, customerTargetId, weight, recordedAt); + await _uow.CommitAsync(); + return weeklyProgress; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + + + public async Task GetCusBasicDetails(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + + var customerData = await _customerRepository.GetCustomerById(cusId); + if (customerData == null) + return new NotFoundException("Customer not Found"); + + return customerData; + } + + public async Task GetCusHealthDetails(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + var customerData = await _customerRepository.GetCusHealthRec(cusId); + if (customerData == null) + return new { message = "no health records" }; + + return customerData; + } + + public async Task GetCusProgressDetails(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + var customerData = await _customerRepository.GetCusProgressRec(cusId); + if (customerData == null) + return new { message = "no progress records" }; + + return customerData + .Select(CustomerProgressHelper.BuildCustomerProgressResponse) + .ToList(); + } + + + public async Task CreateFeed(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + + bool isVerified = await _customerRepository.IsverifiedCus(cusId); + if (!isVerified) + //throw new UnauthorizedException("only for verified users"); + return new { message = "only for verified users" }; + try + { + var FeedResults = await _feedService.CreateFeed(payload, httpContext); + return FeedResults; + } + catch (Exception ex) + { + throw new Exception(ex.Message, ex); + } + } + + + + public async Task FollowMealPlan(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + var data = DeserializePayload.Deserialize(payload); + var mealPlanId = DataExtractor.GetRequiredGuid(data, "mealPlanId"); + await _uow.BeginAsync(); + try + { + var result = await _customerRepository.FollowMealPlan(cusId, mealPlanId); + await _uow.CommitAsync(); + return result; + } + catch (Exception ex) + { + await _uow.RollbackAsync(); + throw new Exception($"Failed to follow meal plan: {ex.Message}"); + } + } + + public async Task FollowWorkOutPlan(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + var data = DeserializePayload.Deserialize(payload); + Guid workOutPlanId = DataExtractor.GetRequiredGuid(data, "workOutPlan"); + await _uow.BeginAsync(); + try + { + var result = await _customerRepository.FollowWorkoutPlan(cusId, workOutPlanId); + await _uow.CommitAsync(); + return result; + } + catch (Exception ex) + { + await _uow.RollbackAsync(); + throw new Exception($"Failed to follow Workout plan: {ex.Message}"); + } + } + + public async Task SetWorkoutExerciseCompletion(Guid workoutPlanId, Guid exerciseId, bool completed, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + + var customerWorkout = await _customerRepository.GetActiveCustomerWorkoutAsync(cusId, workoutPlanId); + if (customerWorkout == null) + throw new KeyNotFoundException("You are not following or assigned to this workout plan."); + + await _uow.BeginAsync(); + try + { + await _customerRepository.SetExerciseCompletionAsync(customerWorkout.CustomerWorkoutId, exerciseId, completed); + await _uow.CommitAsync(); + return new { workoutPlanId, exerciseId, completed }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task GetMyNotifications(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + + var notifications = await _customerNotificationRepository.GetByCustomerIdAsync(cusId); + + return notifications.Select(n => new + { + notificationId = n.CustomerNotificationId, + title = n.Title, + body = n.Body, + notificationType = n.NotificationType, + routeName = n.RouteName, + routeParams = string.IsNullOrEmpty(n.RouteParamsJson) + ? (JsonElement?)null + : JsonSerializer.Deserialize(n.RouteParamsJson), + isRead = n.IsRead, + createdAt = n.CreatedAt + }); + } + + public async Task MarkNotificationRead(Guid notificationId, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + + var notification = await _customerNotificationRepository.GetByIdAsync(notificationId, cusId); + if (notification == null) + throw new KeyNotFoundException("Notification not found."); + + if (notification.IsRead) + return new { notificationId, isRead = true }; + + await _uow.BeginAsync(); + try + { + notification.IsRead = true; + await _uow.CommitAsync(); + return new { notificationId, isRead = true }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task UpdatePushToken(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + string pushToken = DataExtractor.GetRequiredString(data, "pushTokenId"); + + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + + await _uow.BeginAsync(); + try + { + var customer = await _dbContext.Customer.FirstOrDefaultAsync(c => c.CusId == cusId); + if (customer == null) + throw new KeyNotFoundException("Customer not found."); + + customer.PushTokenId = pushToken; + await _uow.CommitAsync(); + return new { success = true }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task UpdateProfileImage(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + int documentId = DataExtractor.GetRequiredInt(data, "documentId"); + + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + + await _uow.BeginAsync(); + try + { + // Only accept a document this same user uploaded, so a customer can't point + // their profile picture at an arbitrary document id belonging to someone else. + var document = await _dbContext.UserDocuments + .FirstOrDefaultAsync(d => d.DocumentId == documentId && d.UserId == userId); + if (document == null) + throw new KeyNotFoundException("Document not found."); + + var customer = await _dbContext.Customer.FirstOrDefaultAsync(c => c.CusId == cusId); + if (customer == null) + throw new KeyNotFoundException("Customer not found."); + + customer.FKProfileImageDocId = documentId; + await _uow.CommitAsync(); + return new { profileImageDocId = documentId }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task GetCardDetails(object payload, HttpContext httpContext) + { + try + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + + var cards = await _customerRepository.GetCardsByCustomerIdAsync(cusId); + return cards.Select(c => new + { + jobId = c.JobId, + cardId = c.CardId, + cardNumber = c.CardNumber, + cardType = c.CardType, + nickName = c.NickName, + isPrimary = c.IsPrimary, + consentGiven = c.ConsentGiven + }); + } + catch + { + throw; + } + } + + // Member self-serve "add card": explicit consent is required up front so a saved card + // can later be used for auto-deduct (see SetAutoCharge). No real PayHere/Stripe SDK is + // wired up anywhere in this backend yet — cardToken is an opaque client-side value, same + // staged convention ActivationService already uses for the platform's own billing. + public async Task SaveCard(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + string cardToken = DataExtractor.GetRequiredString(data, "cardToken"); + bool consent = DataExtractor.GetOptionalBool(data, "consent") ?? false; + if (!consent) + throw new ArgumentException("Consent is required to save a card for auto-deduct"); + + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + Guid clientId = await _userRoleHelper.GetClientIdByCusId(cusId); + + var existing = await _customerRepository.GetCardsByCustomerIdAsync(cusId); + + var card = new CustomerCards + { + FK_CustomerId = cusId, + FK_ClientId = clientId, + CardId = cardToken, + CardNumber = DataExtractor.GetOptionalString(data, "cardLast4"), + CardType = DataExtractor.GetOptionalString(data, "cardType"), + NickName = DataExtractor.GetOptionalString(data, "nickName"), + IsActive = true, + IsPrimary = existing.Count == 0, + ConsentGiven = true, + ConsentAt = DateTime.UtcNow + }; + + var result = await _customerRepository.AddCardAsync(card); + await _uow.CommitAsync(); + + return new { success = true, message = "Card saved", card = result }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + // Lets the mobile app find the caller's active subscription id to target SetAutoCharge. + public async Task GetMySubscription(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + + var subscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(cusId); + if (subscription == null) + return new { hasSubscription = false }; + + return new + { + hasSubscription = true, + subscriptionId = subscription.SubscriptionId, + planName = subscription.CustomerPlan?.PlanName, + autoChargeEnabled = subscription.AutoChargeEnabled, + autoChargeCardId = subscription.FKAutoChargeCardId + }; + } + + // Member opts a subscription in/out of auto-deduct against one of their consented cards. + public async Task SetAutoCharge(Guid subscriptionId, object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + bool enabled = DataExtractor.GetOptionalBool(data, "enabled") ?? false; + + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + + var subscription = await _customerSubscriptionRepository.GetCustomerSubscriptionById(subscriptionId) + ?? throw new NotFoundException("Subscription not found"); + if (subscription.FKCustomerId != cusId) + throw new UnauthorizedException("This subscription does not belong to you"); + + if (enabled) + { + Guid cardId = DataExtractor.GetRequiredGuid(data, "cardId"); + var card = await _customerRepository.GetCardByIdAsync(cardId) + ?? throw new NotFoundException("Card not found"); + if (card.FK_CustomerId != cusId || !card.ConsentGiven) + throw new UnauthorizedException("This card can't be used for auto-deduct"); + + subscription.AutoChargeEnabled = true; + subscription.FKAutoChargeCardId = cardId; + } + else + { + subscription.AutoChargeEnabled = false; + subscription.FKAutoChargeCardId = null; + } + + await _uow.SaveChangesAsync(); + await _uow.CommitAsync(); + + return new + { + success = true, + subscriptionId, + autoChargeEnabled = subscription.AutoChargeEnabled, + autoChargeCardId = subscription.FKAutoChargeCardId + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task GetMyGym(object payload, HttpContext httpContext) + { + try + { + var data = DeserializePayload.Deserialize(payload); + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + + var customerSubscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(cusId); + + if (customerSubscription != null) + { + var customerPlan = await _customerPlanRepository + .GetCustomerPlanById(customerSubscription.FKCustomerPlanId); + + if (customerPlan != null) + { + var client = await _clientRepository.GetClientByIdAsync(customerPlan.FKClientId); + + if (client != null) + { + var clientBusiness = await _clientRepository.GetPrimaryBusinessByClientIdAsync(client.ClientId); + var logo = await BusinessLogoHelper.ReadAsDataUrlAsync(clientBusiness?.BusinessLogoId); + var facilities = GymFacilities.Parse(clientBusiness?.Facilities); + + return new + { + success = true, + clientId = client.ClientId, + clientName = client.ClientName, + clientPhone = client.ClientPhone, + businessName = clientBusiness?.BusinessName, + logo, + address = clientBusiness?.Address, + city = clientBusiness?.City, + hasAc = facilities.HasAc, + equipment = facilities.Equipment, + planName = customerPlan.PlanName, + planDescription = customerPlan.PlanDescription, + planPrice = customerPlan.PlanPrice + }; + } + else + { + return new + { + success = true, + planDescription = customerPlan.PlanDescription, + planPrice = customerPlan.PlanPrice + }; + } + } + } + + return new { success = false, message = "No active gym found" }; + } + catch + { + throw; + } + } + + // Lets the customer leave their own gym (the primary member on their own + // subscription can never be removed via the gym-owner-side RemoveMemberFromSubscription, + // so this is a separate, customer-initiated path). + public async Task UnfollowMyGym(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + + var subscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(cusId); + if (subscription == null) + throw new KeyNotFoundException("You don't have an active gym to unfollow."); + + await _uow.BeginAsync(); + try + { + subscription.IsActive = false; + + var primaryMember = await _customerSubscriptionRepository + .GetActiveSubscriptionMemberByCustomerAsync(subscription.SubscriptionId, cusId); + if (primaryMember != null) + primaryMember.IsActive = false; + + await _uow.CommitAsync(); + return new { success = true, message = "You have left the gym." }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task MyPayment(object payload, HttpContext httpContext) + { + try + { + var data = DeserializePayload.Deserialize(payload); + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid cusId = await _userRoleHelper.GetCusId(userId); + + // Get all customer subscriptions for this customer first + var customerSubscriptionIds = await _tenantDbContext.CustomerSubscriptions + .AsNoTracking() + .Where(cs => cs.FKCustomerId == cusId) + .Select(cs => cs.SubscriptionId) + .ToListAsync(); + + if (!customerSubscriptionIds.Any()) + { + return new { success = false, message = "No subscriptions found" }; + } + + // Get invoices with related data + var customerInvoices = await _tenantDbContext.CustomerInvoices + .AsNoTracking() + .Include(ci => ci.CustomerSubscription) + .ThenInclude(cs => cs.SubscriptionInstallments) + .Where(ci => customerSubscriptionIds.Contains(ci.FKCustomerSubscriptionId)) + .OrderByDescending(ci => ci.CustomerInvoiceDate) + .ToListAsync(); + + if (customerInvoices != null && customerInvoices.Any()) + { + var paymentList = customerInvoices.Select(invoice => new + { + customerInvoiceId = invoice.CustomerInvoiceId, + amount = invoice.Amount, + status = invoice.Status, + invoiceDate = invoice.CustomerInvoiceDate, + subscription = invoice.CustomerSubscription != null ? new + { + subscriptionId = invoice.CustomerSubscription.SubscriptionId, + onboardDate = invoice.CustomerSubscription.OnboardDate, + expiryDate = invoice.CustomerSubscription.ExpiryDate, + renewalDate = invoice.CustomerSubscription.RenewalDate, + isActive = invoice.CustomerSubscription.IsActive, + installments = invoice.CustomerSubscription.SubscriptionInstallments + ?.OrderBy(si => si.InstallmentNo) + .Select(si => new + { + installmentNo = si.InstallmentNo, + dueDate = si.DueDate, + amount = si.Amount, + paidAmount = si.PaidAmount, + status = si.Status + }).ToList() + } : null + }).ToList(); + + return new + { + success = true, + payments = paymentList + }; + } + + return new { success = false, message = "No payments found" }; + } + catch + { + throw; + } + } + } +} diff --git a/Services/CustomerSubscriptionService.cs b/Services/CustomerSubscriptionService.cs new file mode 100644 index 0000000..d726a16 --- /dev/null +++ b/Services/CustomerSubscriptionService.cs @@ -0,0 +1,510 @@ +using Fitness_core_BE.Domain; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.DTOs.CustomerSubscription; +using System.Text.Json; + +namespace Fitness_core_BE.Services.CustomerSubscription +{ + public class CustomerSubscriptionService : ICustomerSubscriptionService + { + private readonly ICustomerSubscriptionRepository _customerSubscriptionRepository; + private readonly ICustomerInvoiceRepository _customerInvoiceRepository; + private readonly IUnitOfWork _uow; + private readonly UserRoleHelper _userRoleHelper; + + public CustomerSubscriptionService( + ICustomerSubscriptionRepository customerSubscriptionRepository, + ICustomerInvoiceRepository customerInvoiceRepository, + IUnitOfWork uow, + UserRoleHelper userRoleHelper) + { + _customerSubscriptionRepository = customerSubscriptionRepository; + _customerInvoiceRepository = customerInvoiceRepository; + _uow = uow; + _userRoleHelper = userRoleHelper; + } + + public async Task CreateCustomerSubscription(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + if (!data!.ContainsKey("customerId")) + { + throw new KeyNotFoundException("customerId is required"); + } + + if (!data.ContainsKey("customerPlanId")) + { + throw new KeyNotFoundException("customerPlanId is required"); + } + + var customerId = Guid.Parse(data["customerId"].GetString()!); + var customerPlanId = Guid.Parse(data["customerPlanId"].GetString()!); + + // Get plan details for creating the initial sub-item + var plan = await _customerSubscriptionRepository + .GetPlanByIdAsync(customerPlanId); + if (plan == null) + { + throw new KeyNotFoundException("Customer Plan not found"); + } + + // Create subscription + var subscription = new Models.CustomerSubscription + { + FKCustomerId = customerId, + FKCustomerPlanId = customerPlanId, + OnboardDate = DataExtractor.GetOptionalDateTime(data, "onboardDate") ?? DateTime.UtcNow, + ExpiryDate = DataExtractor.GetOptionalDateTime(data, "expiryDate"), + IsActive = true + }; + + // Create initial sub-item representing the main plan + var initialSubItem = new CustomerSubItem + { + SubItemName = plan.PlanName, + SubItemDescription = plan.PlanDescription, + SubItemPrice = plan.PlanPrice, + IsActive = true + }; + + var aggregate = CustomerSubscriptionFactory.Create( + subscription, plan, initialSubItem); + await _customerSubscriptionRepository.AddCustomerSubscriptionAsync( + aggregate.Subscription, + aggregate.InitialSubItem, + aggregate.PrimaryMember, + aggregate.Installments); + await _uow.CommitAsync(); + + return new + { + subscriptionId = subscription.SubscriptionId, + customerId = subscription.FKCustomerId, + customerPlanId = subscription.FKCustomerPlanId, + onboardDate = subscription.OnboardDate, + expiryDate = subscription.ExpiryDate, + isActive = subscription.IsActive, + message = "Customer subscription created successfully with initial plan item" + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task GetCustomerSubscriptionById(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + + if (!data!.ContainsKey("subscriptionId")) + { + throw new KeyNotFoundException("subscriptionId is required"); + } + + var subscriptionId = Guid.Parse(data["subscriptionId"].GetString()!); + var subscription = await _customerSubscriptionRepository.GetCustomerSubscriptionById(subscriptionId); + + if (subscription == null) + { + throw new KeyNotFoundException("Customer Subscription not found"); + } + + // Get sub-items + var subItems = (await _customerSubscriptionRepository.GetSubItemsAsync( + subscriptionId, activeOnly: false, newestFirst: false)) + .Select(ToSubItemDto) + .ToList(); + + return new CustomerSubscriptionDto + { + SubscriptionId = subscription.SubscriptionId, + CustomerId = subscription.FKCustomerId, + CustomerName = subscription.TenantCustomerLink?.DisplayName, + CustomerPlanId = subscription.FKCustomerPlanId, + PlanName = subscription.CustomerPlan?.PlanName, + PlanPrice = subscription.CustomerPlan?.PlanPrice ?? 0, + DurationInDays = subscription.CustomerPlan?.DurationInDays ?? 0, + OnboardDate = subscription.OnboardDate, + ExpiryDate = subscription.ExpiryDate, + RenewalDate = subscription.RenewalDate, + IsActive = subscription.IsActive, + CreatedAt = subscription.CreatedAt, + DiscountType = subscription.DiscountType, + DiscountValue = subscription.DiscountValue, + SubItems = subItems + }; + } + + public async Task GetCustomerSubscriptionsByCustomerId(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + + if (!data!.ContainsKey("customerId")) + { + throw new KeyNotFoundException("customerId is required"); + } + + var customerId = Guid.Parse(data["customerId"].GetString()!); + var subscriptions = await _customerSubscriptionRepository.GetCustomerSubscriptionsByCustomerId(customerId); + + return subscriptions.Select(s => new + { + subscriptionId = s.SubscriptionId, + customerPlanId = s.FKCustomerPlanId, + planName = s.CustomerPlan?.PlanName, + planPrice = s.CustomerPlan?.PlanPrice, + durationInDays = s.CustomerPlan?.DurationInDays, + onboardDate = s.OnboardDate, + expiryDate = s.ExpiryDate, + renewalDate = s.RenewalDate, + isActive = s.IsActive, + createdAt = s.CreatedAt + }).ToList(); + } + + public async Task GetActiveSubscriptionByCustomerId(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + + if (!data!.ContainsKey("customerId")) + { + throw new KeyNotFoundException("customerId is required"); + } + + var customerId = Guid.Parse(data["customerId"].GetString()!); + var subscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(customerId); + + if (subscription == null) + { + return new { message = "No active subscription found for this customer" }; + } + + // Get sub-items + var subItems = (await _customerSubscriptionRepository.GetSubItemsAsync( + subscription.SubscriptionId, + activeOnly: false, + newestFirst: false)) + .Select(ToSubItemDto) + .ToList(); + + return new CustomerSubscriptionDto + { + SubscriptionId = subscription.SubscriptionId, + CustomerId = subscription.FKCustomerId, + CustomerName = subscription.TenantCustomerLink?.DisplayName, + CustomerPlanId = subscription.FKCustomerPlanId, + PlanName = subscription.CustomerPlan?.PlanName, + PlanPrice = subscription.CustomerPlan?.PlanPrice ?? 0, + DurationInDays = subscription.CustomerPlan?.DurationInDays ?? 0, + OnboardDate = subscription.OnboardDate, + ExpiryDate = subscription.ExpiryDate, + RenewalDate = subscription.RenewalDate, + IsActive = subscription.IsActive, + CreatedAt = subscription.CreatedAt, + DiscountType = subscription.DiscountType, + DiscountValue = subscription.DiscountValue, + SubItems = subItems + }; + } + + // ---------- Group membership & summaries (client dashboard) ---------- + + public async Task GetSubscriptionSummary(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + var subscriptionId = Guid.Parse(data!["subscriptionId"].GetString()!); + + var subscription = await LoadAuthorizedSubscription(subscriptionId, httpContext); + return await BuildSummaryAsync(subscription); + } + + public async Task AddMemberToSubscription(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + var subscriptionId = Guid.Parse(data!["subscriptionId"].GetString()!); + var customerId = Guid.Parse(data["customerId"].GetString()!); + + var subscription = await LoadAuthorizedSubscription(subscriptionId, httpContext); + var plan = subscription.CustomerPlan; + + if (plan == null || plan.PlanScope != "Group") + throw new ArgumentException("Members can only be added to Group plans"); + + var activeCount = await _customerSubscriptionRepository.GetActiveMemberCountAsync(subscriptionId); + if (activeCount >= plan.MemberLimit) + throw new ArgumentException($"This plan allows at most {plan.MemberLimit} members"); + + var customerExists = await _customerSubscriptionRepository + .IsActiveCustomerAsync(customerId); + if (!customerExists) + throw new KeyNotFoundException("Customer not found"); + + var alreadyMember = await _customerSubscriptionRepository + .GetActiveSubscriptionMemberByCustomerAsync(subscriptionId, customerId); + if (alreadyMember != null) + throw new ArgumentException("This customer is already a member of the subscription"); + + // One customer, one plan at a time -- catches both "already primary on their own + // plan" and "already a secondary on a different group", neither of which the + // check above sees (it only looks at this one subscription). + var hasOtherActivePlan = await _customerSubscriptionRepository.HasAnyActiveSubscriptionAsync(customerId); + if (hasOtherActivePlan) + throw new ArgumentException("This customer already has an active plan. Remove them from it before adding them to another."); + + var member = await _customerSubscriptionRepository.AddSubscriptionMemberAsync(new SubscriptionMember + { + FKSubscriptionId = subscriptionId, + FKCustomerId = customerId, + IsPrimary = false + }); + + await _uow.CommitAsync(); + + return new + { + success = true, + subscriptionMemberId = member.SubscriptionMemberId, + memberCount = activeCount + 1, + memberLimit = plan.MemberLimit + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task RemoveMemberFromSubscription(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + var subscriptionId = Guid.Parse(data!["subscriptionId"].GetString()!); + var subscriptionMemberId = Guid.Parse(data["subscriptionMemberId"].GetString()!); + + await LoadAuthorizedSubscription(subscriptionId, httpContext); + + var member = await _customerSubscriptionRepository.GetSubscriptionMemberAsync(subscriptionMemberId); + if (member == null || member.FKSubscriptionId != subscriptionId || !member.IsActive) + throw new KeyNotFoundException("Subscription member not found"); + + if (member.IsPrimary) + throw new ArgumentException("The primary member cannot be removed from a subscription"); + + member.IsActive = false; + + await _uow.CommitAsync(); + return new { success = true }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + /// Typed variant used by ClientService.GetCustomerDetailsById (covers group secondaries). + public async Task> BuildSummariesForCustomerAsync(Guid customerId) + { + var subscriptions = await _customerSubscriptionRepository.GetSubscriptionsForCustomerAsync(customerId); + var summaries = new List(subscriptions.Count); + foreach (var subscription in subscriptions) + { + summaries.Add(await BuildSummaryAsync(subscription)); + } + return summaries; + } + + public async Task> GetExtraFeesAsync(Guid subscriptionId, HttpContext httpContext) + { + await LoadAuthorizedSubscription(subscriptionId, httpContext); + + return (await _customerSubscriptionRepository.GetSubItemsAsync( + subscriptionId, activeOnly: true, newestFirst: true)) + .Select(ToSubItemDto) + .ToList(); + } + + public async Task AddExtraFeeAsync(Guid subscriptionId, AddExtraFeeRequest request, HttpContext httpContext) + { + await LoadAuthorizedSubscription(subscriptionId, httpContext); + + if (string.IsNullOrWhiteSpace(request.Name)) + throw new ArgumentException("A name is required for the extra fee."); + if (request.Amount <= 0) + throw new ArgumentException("Amount must be greater than zero."); + if (request.Frequency != "OneTime" && request.Frequency != "Recurring") + throw new ArgumentException("Frequency must be 'OneTime' or 'Recurring'."); + + await _uow.BeginAsync(); + try + { + var subItem = new CustomerSubItem + { + CustomerSubItemId = Guid.NewGuid(), + FKCustomerSubscriptionId = subscriptionId, + SubItemName = request.Name, + SubItemDescription = request.Description, + SubItemPrice = request.Amount, + Frequency = request.Frequency, + IsActive = true, + CreatedAt = DateTime.UtcNow + }; + await _customerSubscriptionRepository.AddSubItemAsync(subItem); + + // Charged immediately as its own invoice — the subscription's original + // invoice is idempotent and won't retroactively pick up items added later. + var invoice = await _customerInvoiceRepository.CreateSupplementalInvoiceAsync( + subscriptionId, request.Name, request.Description, request.Amount); + + await _uow.CommitAsync(); + + return new + { + customerSubItemId = subItem.CustomerSubItemId, + customerInvoiceId = invoice.CustomerInvoiceId, + amount = invoice.Amount, + frequency = subItem.Frequency + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + private async Task LoadAuthorizedSubscription(Guid subscriptionId, HttpContext httpContext) + { + var subscription = await _customerSubscriptionRepository.GetCustomerSubscriptionById(subscriptionId); + if (subscription == null) + throw new KeyNotFoundException("Subscription not found"); + + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + if (subscription.CustomerPlan == null || subscription.CustomerPlan.FKClientId != clientId) + throw new UnauthorizedAccessException("This subscription does not belong to your gym"); + + return subscription; + } + + private async Task BuildSummaryAsync(Models.CustomerSubscription subscription) + { + var plan = subscription.CustomerPlan; + + var invoice = await _customerSubscriptionRepository + .GetLatestInvoiceAsync(subscription.SubscriptionId); + + decimal invoiceAmount = invoice?.Amount ?? plan?.PlanPrice ?? 0m; + decimal paid = 0m; + if (invoice != null) + { + paid = await _customerSubscriptionRepository + .GetApprovedPaymentTotalAsync(invoice.CustomerInvoiceId); + } + + var members = (subscription.SubscriptionMembers ?? new List()) + .Where(m => m.IsActive) + .OrderByDescending(m => m.IsPrimary) + .ThenBy(m => m.JoinedAt) + .Select(m => new SubscriptionMemberDto + { + SubscriptionMemberId = m.SubscriptionMemberId, + CustomerId = m.FKCustomerId, + CusName = m.TenantCustomerLink?.DisplayName, + Email = m.TenantCustomerLink?.EmailCached, + PrimaryPH = m.TenantCustomerLink?.PrimaryPhoneCached, + IsPrimary = m.IsPrimary, + JoinedAt = m.JoinedAt, + IsActive = m.IsActive, + IsPlanAssigned = m.IsPlanAssigned + }) + .ToList(); + + var installments = (subscription.SubscriptionInstallments ?? new List()) + .Where(i => i.IsActive) + .OrderBy(i => i.InstallmentNo) + .Select(i => new SubscriptionInstallmentDto + { + SubscriptionInstallmentId = i.SubscriptionInstallmentId, + InstallmentNo = i.InstallmentNo, + DueDate = i.DueDate, + Amount = i.Amount, + PaidAmount = i.PaidAmount, + Status = i.Status + }) + .ToList(); + + AutoChargeCardDto? autoChargeCard = null; + if (subscription.FKAutoChargeCardId.HasValue) + { + var card = await _customerSubscriptionRepository + .GetCardByIdAsync(subscription.FKAutoChargeCardId.Value); + if (card != null) + { + autoChargeCard = new AutoChargeCardDto + { + CardId = card.JobId, + CardLast4 = card.CardNumber, + CardType = card.CardType + }; + } + } + + return new SubscriptionSummaryDto + { + SubscriptionId = subscription.SubscriptionId, + CustomerPlanId = subscription.FKCustomerPlanId, + PlanName = plan?.PlanName, + PlanScope = plan?.PlanScope ?? "Individual", + MemberLimit = plan?.MemberLimit ?? 1, + DurationMode = plan?.DurationMode ?? "Rolling", + AllowSplitPayments = plan?.AllowSplitPayments ?? false, + PlanPrice = plan?.PlanPrice ?? 0m, + OnboardDate = subscription.OnboardDate, + ExpiryDate = subscription.ExpiryDate, + RenewalDate = subscription.RenewalDate, + IsActive = subscription.IsActive, + CustomerInvoiceId = invoice?.CustomerInvoiceId, + InvoiceAmount = invoiceAmount, + PaidAmount = paid, + Balance = invoiceAmount - paid, + PaymentStatus = paid >= invoiceAmount && invoiceAmount > 0 + ? "Paid" + : paid > 0 ? "Partial" : "Pending", + Members = members, + Installments = installments, + AutoChargeEnabled = subscription.AutoChargeEnabled, + AutoChargeCard = autoChargeCard + }; + } + + private static CustomerSubItemDto ToSubItemDto(CustomerSubItem item) => new() + { + CustomerSubItemId = item.CustomerSubItemId, + SubItemName = item.SubItemName, + SubItemDescription = item.SubItemDescription, + SubItemPrice = item.SubItemPrice, + Frequency = item.Frequency, + IsActive = item.IsActive, + CreatedAt = item.CreatedAt + }; + } +} + +// add function to add new sub-item to existing subscription, which can be used for add-on purchases diff --git a/Services/DocumentService.cs b/Services/DocumentService.cs new file mode 100644 index 0000000..feba6f6 --- /dev/null +++ b/Services/DocumentService.cs @@ -0,0 +1,263 @@ +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using Fitness_core_BE.Infra.UoW; +using System.Text.Json; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.DTOs; + +namespace Fitness_core_BE.Services.Document +{ + public class DocumentService : IDocumentService + { + private readonly IDocumentRepository _docRepository; + private readonly IUnitOfWork _uow; + + public DocumentService(IDocumentRepository docRepository, IUnitOfWork uow) + { + _docRepository = docRepository; + _uow = uow; + } + + public async Task UploadDocument(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + + var data = DeserializePayload.Deserialize(payload); + + var files = data["document"].EnumerateArray(); + bool isTicketDoc = data.TryGetValue("IsTicketDoc", out JsonElement isTicketDocElement) && isTicketDocElement.GetBoolean(); + var uploadResults = new List(); + Guid? ticketId = null; + if (isTicketDoc && data.TryGetValue("TicketId", out JsonElement ticketIdElement)) + { + ticketId = ticketIdElement.GetGuid(); + } + string uploadsRoot = "./desktop"; + + + if (isTicketDoc && ticketId.HasValue) + { + // uploadsRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "Uploads", userId.ToString(), ticketId.ToString())); + uploadsRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "Uploads", userId.ToString(), ticketId.Value.ToString())); + + } + else + { + // uploadsRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "Uploads", userId.ToString())); + uploadsRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "Uploads", userId.ToString())); + } + + if (!Directory.Exists(uploadsRoot)) + { + Directory.CreateDirectory(uploadsRoot); + } + + foreach (var file in files) + { + string lastnum = await _docRepository.GetLastSequnce(); + string originalFileName = file.GetProperty("FileName").GetString() ?? "document"; + string extension = Path.GetExtension(originalFileName).ToLowerInvariant(); + + string[] allowedExtensions = { ".pdf", ".jpg", ".jpeg", ".png", ".docx", ".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp" }; + if (!allowedExtensions.Contains(extension)) + throw new BadRequestException("File type not allowed."); + + string safeTimestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + string filename = $"{lastnum}_{userId}_{safeTimestamp}{extension}"; + + string baseFile = file.GetProperty("Base64").GetString(); + if (baseFile.Contains(",")) + baseFile = baseFile.Substring(baseFile.IndexOf(",") + 1); + byte[] fileBytes = Convert.FromBase64String(baseFile); + + if (fileBytes.Length > 10 * 1024 * 1024) + throw new BadRequestException("File too large."); + + string filePath = Path.Combine(uploadsRoot, filename); + await File.WriteAllBytesAsync(filePath, fileBytes); + + // Extract FileType from the file element + var fileData = new Dictionary { { "FileType", file.GetProperty("FileType") } }; + int docTypeId = DataExtractor.GetRequiredInt(fileData, "FileType"); + + UserDocument documentMeta = new UserDocument { }; + + if (isTicketDoc && ticketId.HasValue) + { + documentMeta = new UserDocument + { + UserId = userId, + DocumentName = originalFileName, + DocumentUrl = $"/Uploads/{userId}/{ticketId.Value}/{Uri.EscapeDataString(filename)}", + FkDocumentTypeId = docTypeId, + }; + } + else + { + documentMeta = new UserDocument + { + UserId = userId, + DocumentName = originalFileName, + DocumentUrl = $"/Uploads/{userId}/{Uri.EscapeDataString(filename)}", + FkDocumentTypeId = docTypeId, + }; + } + + var savedDocument = await _docRepository.UploadDocumentAsync(documentMeta); + await _uow.SaveChangesAsync(); + uploadResults.Add(new + { + savedDocument.DocumentId, + savedDocument.DocumentName, + DocumentURL = savedDocument.DocumentUrl + }); + } + + await _uow.CommitAsync(); + return new { Message = "Documents uploaded successfully.", uploadResults }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task GetDocumentsByUserId(object payload, HttpContext httpContext) + { + // TODO: Uncomment for production - Currently using static userId for testing + // if (!Guid.TryParse(httpContext.User.FindFirst("UserId")?.Value, out Guid userId)) + // { + // throw new InvalidOperationException("Invalid UserId in token."); + // } + + Guid userId = Guid.Parse("5b664468-9ba5-4e6e-b73a-97c9a791c9a0"); + + List docResults = await _docRepository.GetDocumentsByUserId(userId); + + if (docResults == null || docResults.Count == 0) + return new List(); + + string uploadsRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..")); + + var documents = new List(); + + foreach (var doc in docResults) + { + if (string.IsNullOrEmpty(doc.DocumentUrl)) + continue; + + string relativePath = doc.DocumentUrl.Replace("/", Path.DirectorySeparatorChar.ToString()); + Console.WriteLine("Relative Path: " + relativePath); + + string filePath = uploadsRoot + relativePath; + Console.WriteLine("File Path: " + filePath); + + if (!File.Exists(filePath)) + continue; // Skip missing files safely + + byte[] fileBytes = await File.ReadAllBytesAsync(filePath); + + documents.Add(new + { + FileName = doc.DocumentName, + FileType = Path.GetExtension(doc.DocumentName), + doc.DocumentUrl, + doc.DocumentType.DocumentType, + Data = Convert.ToBase64String(fileBytes) + }); + } + + return documents; + } + + public async Task GetDocumentById(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + var documentId = DataExtractor.GetRequiredInt(data, "documentId"); + var document = await _docRepository.GetDocumentById(documentId); + + if (document != null && !string.IsNullOrEmpty(document.DocumentUrl)) + { + string uploadsRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..")); + Console.WriteLine("Uploads Root: " + uploadsRoot); + + string relativePath = document.DocumentUrl.Replace("/", Path.DirectorySeparatorChar.ToString()); + Console.WriteLine("Relative Path: " + relativePath); + + string filePath = uploadsRoot + relativePath; + Console.WriteLine("File Path: " + filePath); + + if (!File.Exists(filePath)) + throw new FileNotFoundException("Document file not found."); + + byte[] fileBytes = await File.ReadAllBytesAsync(filePath); + + return new + { + FileName = document.DocumentName, + FileType = Path.GetExtension(document.DocumentName), + document.DocumentUrl, + document.DocumentType.DocumentType, + Data = Convert.ToBase64String(fileBytes) + }; + } + + throw new InvalidOperationException("Document not found or invalid document data."); + } + + public async Task RemoveDocument(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + var documentId = DataExtractor.GetRequiredInt(data, "documentId"); + var result = await _docRepository.RemoveDocument(documentId); + + if (!result) + { + throw new NotFoundException("Document not found"); + } + + await _uow.CommitAsync(); + + return new { success = true, message = "Document removed successfully" }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task GetFileBytesById(int documentId) + { + var document = await _docRepository.GetDocumentById(documentId); + + if (document == null || string.IsNullOrEmpty(document.DocumentUrl)) + throw new NotFoundException("Document record not found."); + + string uploadsRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..")); + string relativePath = document.DocumentUrl.Replace("/", Path.DirectorySeparatorChar.ToString()); + string filePath = uploadsRoot + relativePath; + + if (!File.Exists(filePath)) + throw new FileNotFoundException("Physical file is missing on the server."); + + byte[] fileBytes = await File.ReadAllBytesAsync(filePath); + + return new DocumentDownloadModel + { + FileName = document.DocumentName, + FileType = Path.GetExtension(document.DocumentName), + FileData = fileBytes + }; + } + } +} diff --git a/Services/FacilityService.cs b/Services/FacilityService.cs new file mode 100644 index 0000000..c97c85f --- /dev/null +++ b/Services/FacilityService.cs @@ -0,0 +1,262 @@ +using Fitness_core_BE.DTOs.Facility; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using System.Text.Json; + +namespace Fitness_core_BE.Services +{ + public class FacilityService : IFacilityService + { + private static readonly string[] ValidFacilityTypes = { "Gym", "Pool", "Court", "Class" }; + + private readonly IFacilityRepository _facilityRepository; + private readonly IUnitOfWork _uow; + private readonly UserRoleHelper _userRoleHelper; + + public FacilityService(IFacilityRepository facilityRepository, IUnitOfWork uow, UserRoleHelper userRoleHelper) + { + _facilityRepository = facilityRepository; + _uow = uow; + _userRoleHelper = userRoleHelper; + } + + public async Task GetAllFacilities(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + bool includeInactive = data.ContainsKey("includeInactive") && data["includeInactive"].GetBoolean(); + + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + var facilities = await _facilityRepository.GetAllFacilities(clientId, includeInactive); + + return facilities.Select(MapToDto).ToList(); + } + + public async Task CreateFacility(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + if (!data.ContainsKey("name")) throw new KeyNotFoundException("name is required"); + if (!data.ContainsKey("facilityType")) throw new KeyNotFoundException("facilityType is required"); + + var facilityType = data["facilityType"].GetString() ?? ""; + if (!ValidFacilityTypes.Contains(facilityType)) + throw new ArgumentException($"facilityType must be one of: {string.Join(", ", ValidFacilityTypes)}"); + + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + + var facility = new Facility + { + FKClientId = clientId, + Name = data["name"].GetString() ?? "", + FacilityType = facilityType, + Description = data.ContainsKey("description") ? data["description"].GetString() : null, + DefaultCapacity = data.ContainsKey("defaultCapacity") ? data["defaultCapacity"].GetInt32() : 1 + }; + + if (facility.DefaultCapacity < 1) + throw new ArgumentException("defaultCapacity must be at least 1"); + + var result = await _facilityRepository.CreateFacility(facility); + await _uow.CommitAsync(); + + return MapToDto(result); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task UpdateFacility(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + if (!data.ContainsKey("facilityId")) throw new KeyNotFoundException("facilityId is required"); + + var facilityId = Guid.Parse(data["facilityId"].GetString()!); + var existing = await _facilityRepository.GetFacilityById(facilityId) + ?? throw new KeyNotFoundException("Facility not found"); + + if (data.ContainsKey("name")) existing.Name = data["name"].GetString() ?? existing.Name; + if (data.ContainsKey("facilityType")) + { + var facilityType = data["facilityType"].GetString() ?? existing.FacilityType; + if (!ValidFacilityTypes.Contains(facilityType)) + throw new ArgumentException($"facilityType must be one of: {string.Join(", ", ValidFacilityTypes)}"); + existing.FacilityType = facilityType; + } + if (data.ContainsKey("description")) existing.Description = data["description"].GetString(); + if (data.ContainsKey("defaultCapacity")) + { + var capacity = data["defaultCapacity"].GetInt32(); + if (capacity < 1) throw new ArgumentException("defaultCapacity must be at least 1"); + existing.DefaultCapacity = capacity; + } + if (data.ContainsKey("isActive")) existing.IsActive = data["isActive"].GetBoolean(); + + var result = await _facilityRepository.UpdateFacility(existing); + await _uow.CommitAsync(); + + return MapToDto(result); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task SetFacilityStatus(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + var facilityId = Guid.Parse(data["facilityId"].GetString()!); + var isActive = data["isActive"].GetBoolean(); + + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + var facility = await _facilityRepository.SetFacilityActive(facilityId, clientId, isActive) + ?? throw new KeyNotFoundException("Facility not found"); + + await _uow.CommitAsync(); + return MapToDto(facility); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task CreateFacilitySession(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + if (!data.ContainsKey("facilityId")) throw new KeyNotFoundException("facilityId is required"); + if (!data.ContainsKey("startTime")) throw new KeyNotFoundException("startTime is required"); + if (!data.ContainsKey("endTime")) throw new KeyNotFoundException("endTime is required"); + + var facilityId = Guid.Parse(data["facilityId"].GetString()!); + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + + var facility = await _facilityRepository.GetFacilityById(facilityId); + if (facility == null || facility.FKClientId != clientId) + throw new KeyNotFoundException("Facility not found"); + + var startTime = DateTime.Parse(data["startTime"].GetString()!).ToUniversalTime(); + var endTime = DateTime.Parse(data["endTime"].GetString()!).ToUniversalTime(); + if (endTime <= startTime) + throw new ArgumentException("endTime must be after startTime"); + + int? capacity = data.ContainsKey("capacity") && data["capacity"].ValueKind != JsonValueKind.Null + ? data["capacity"].GetInt32() + : null; + if (capacity.HasValue && capacity.Value < 1) + throw new ArgumentException("capacity must be at least 1 when set"); + + var session = new FacilitySession + { + FKFacilityId = facilityId, + FKClientId = clientId, + StartTime = startTime, + EndTime = endTime, + Capacity = capacity + }; + + var result = await _facilityRepository.CreateFacilitySession(session); + await _uow.CommitAsync(); + + return MapToDto(result, facility.Name, 0); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task GetSessions(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + + Guid? facilityId = data.ContainsKey("facilityId") && data["facilityId"].ValueKind != JsonValueKind.Null + ? Guid.Parse(data["facilityId"].GetString()!) + : null; + + var fromUtc = data.ContainsKey("fromDate") && data["fromDate"].ValueKind != JsonValueKind.Null + ? DateTime.Parse(data["fromDate"].GetString()!).ToUniversalTime() + : DateTime.UtcNow.Date; + var toUtc = data.ContainsKey("toDate") && data["toDate"].ValueKind != JsonValueKind.Null + ? DateTime.Parse(data["toDate"].GetString()!).ToUniversalTime() + : fromUtc.AddDays(7); + + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + var sessions = await _facilityRepository.GetSessions(clientId, facilityId, fromUtc, toUtc); + + var result = new List(); + foreach (var session in sessions) + { + var bookedCount = await _facilityRepository.GetActiveBookingCount(session.FacilitySessionId); + result.Add(MapToDto(session, session.Facility?.Name, bookedCount)); + } + + return result; + } + + public async Task CancelFacilitySession(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + var facilitySessionId = Guid.Parse(data["facilitySessionId"].GetString()!); + + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + var session = await _facilityRepository.CancelFacilitySession(facilitySessionId, clientId) + ?? throw new KeyNotFoundException("Facility session not found"); + + await _uow.CommitAsync(); + return MapToDto(session); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + private static FacilityDto MapToDto(Facility facility) => new() + { + FacilityId = facility.FacilityId, + Name = facility.Name, + FacilityType = facility.FacilityType, + Description = facility.Description, + DefaultCapacity = facility.DefaultCapacity, + IsActive = facility.IsActive, + CreatedAt = facility.CreatedAt + }; + + private static FacilitySessionDto MapToDto(FacilitySession session, string? facilityName = null, int bookedCount = 0) => new() + { + FacilitySessionId = session.FacilitySessionId, + FacilityId = session.FKFacilityId, + FacilityName = facilityName ?? session.Facility?.Name, + StartTime = session.StartTime, + EndTime = session.EndTime, + Capacity = session.Capacity ?? session.Facility?.DefaultCapacity ?? 1, + BookedCount = bookedCount, + IsCancelled = session.IsCancelled + }; + } +} diff --git a/Services/FeedService.cs b/Services/FeedService.cs new file mode 100644 index 0000000..9c744dc --- /dev/null +++ b/Services/FeedService.cs @@ -0,0 +1,355 @@ +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using Fitness_core_BE.Models.IOs; + + + +namespace Fitness_core_BE.Services.Feed +{ + public class FeedService : IFeedService + { + // DocumentTypes.DocumentId for FEED_POST (seeded in CentralDbContext/AppDBContext). + private const int FeedPostDocumentTypeId = 1005; + + private readonly IFeedRepository _repository; + private readonly UserRoleHelper _userRoleHelper; + private readonly IUnitOfWork _uow; + private readonly IActivityLogService _activityLogService; + private readonly IDocumentService _documentService; + private readonly IPushNotificationService _pushNotificationService; + + public FeedService(IFeedRepository repository, UserRoleHelper userRoleHelper, IUnitOfWork uow, IActivityLogService activityLogService, IDocumentService documentService, IPushNotificationService pushNotificationService) + { + _repository = repository; + _userRoleHelper = userRoleHelper; + _uow = uow; + _activityLogService = activityLogService; + _documentService = documentService; + _pushNotificationService = pushNotificationService; + } + + + + + + + + + + public async Task GetFeedPostById(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid postId = DataExtractor.GetRequiredGuid(data, "postId"); + + FeedPost post = await _repository.GetFeedPostByIdAsync(userId, postId); + + return post; + } + + + + + + + + + + public async Task GetFeedForCustomer(object payload, HttpContext httpContext) + { + try + { + var data = DeserializePayload.Deserialize(payload); + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + int skip = data.ContainsKey("skip") && data["skip"].TryGetInt32(out var skipValue) ? skipValue : 0; + int take = data.ContainsKey("take") && data["take"].TryGetInt32(out var takeValue) ? takeValue : 10; + + // A customer with no active gym has no single client feed to scope to — the + // repository already falls back to the platform-wide feed for them (clientId + // stays null), so this no longer short-circuits to an empty list. + var (clientId, feedList) = + await _repository.GetFeedForCustomerAsync(userId, skip, take); + + var feedListWithLikeCount = new List(); + foreach (var feed in feedList) + { + var likeCount = await _repository.GetLikeCountAsync(feed.FeedPostId); + feedListWithLikeCount.Add(new + { + feedPostId = feed.FeedPostId, + caption = feed.Caption, + description = feed.Description, + userId = feed.FKUserId, + docId = feed.FkDocId, + isActive = feed.IsActive, + createdAt = feed.CreatedAt, + creator = feed.User.UserName, + likeCount, + clientId = clientId + }); + } + + return feedListWithLikeCount; + } + catch + { + throw; + } + } + + //like + public async Task LikeAsync(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid postId = Guid.Parse(data["postId"].GetString()!); + + + if (!await _repository.IsLikedAsync(postId, userId)) + { + await _repository.AddLikeAsync(postId, userId); + await _uow.CommitAsync(); + + return new { success = true, message = "Post liked successfully" }; + } + + return new { success = false, message = "Post already liked" }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + + + //check like + public async Task IsLikedAsync(object payload, HttpContext httpContext) + { + try + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + var data = DeserializePayload.Deserialize(payload); + Guid postId = Guid.Parse(data["postId"].GetString()!); + bool isLiked = await _repository.IsLikedAsync(postId, userId); + return new { isLiked }; + } + catch + { + throw; + } + } + + + public async Task UnlikeAsync(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + Guid postId = Guid.Parse(data["postId"].GetString()!); + Guid userId = Guid.Parse(data["userId"].GetString()!); + + if (await _repository.IsLikedAsync(postId, userId)) + { + await _repository.RemoveLikeAsync(postId, userId); + await _uow.CommitAsync(); + + return new { success = true, message = "Post unliked successfully" }; + } + + return new { success = false, message = "Post not liked yet" }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + + + + + //create feed + public async Task CreateFeed(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + + // Tag every attachment with the FEED_POST document type unless the caller + // already chose one. Must match a DocumentTypes row (UserDocuments FKs to it). + object uploadPayload = payload; + + if (data.TryGetValue("document", out var documentsElement) && + documentsElement.ValueKind == System.Text.Json.JsonValueKind.Array) + { + var payloadJson = System.Text.Json.JsonSerializer.Serialize(data); + var payloadDict = System.Text.Json.JsonSerializer.Deserialize>( + payloadJson) ?? new Dictionary(); + + var documentsArray = System.Text.Json.JsonSerializer.Deserialize>>( + documentsElement.GetRawText()) ?? new List>(); + + foreach (var document in documentsArray) + { + if (!document.ContainsKey("FileType")) + { + document["FileType"] = FeedPostDocumentTypeId; + } + } + + payloadDict["document"] = documentsArray; + uploadPayload = payloadDict; + } + + var docResponseObj = await _documentService.UploadDocument(uploadPayload, httpContext); + + dynamic docResponse = docResponseObj; + + if (docResponse == null || docResponse?.uploadResults == null || docResponse?.uploadResults.Count == 0) + { + await _uow.RollbackAsync(); + return new { success = false, message = "Document upload failed" }; + } + + var firstDoc = docResponse?.uploadResults[0]; + int docId = firstDoc?.DocumentId ?? 0; + + var feedPost = new FeedPost + { + Caption = data.ContainsKey("caption") ? data["caption"].GetString() : null, + Description = data.ContainsKey("description") ? data["description"].GetString() : null, + FkDocId = docId, + FKUserId = userId + }; + + await _repository.CreateFeedAsync(feedPost); + + await _uow.CommitAsync(); + + try + { + var customerIds = await _repository.GetActiveCustomerIdsByPosterUserIdAsync(userId); + if (customerIds.Count > 0) + { + var caption = feedPost.Caption; + var body = string.IsNullOrWhiteSpace(caption) + ? "Your gym shared a new post. Tap to view." + : caption!.Length > 120 ? caption[..120] + "…" : caption; + + await _pushNotificationService.NotifyCustomersAsync( + customerIds, + "New post from your gym", + body, + "FeedPostCreated", + "MainTabs", + new { screen = "Connect" } + ); + } + } + catch (Exception notifyEx) + { + Console.WriteLine($"Push notification failed (non-critical): {notifyEx.Message}"); + } + + return new { success = true, message = "Feed created successfully" }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task ShowFeedByClient(object payload, HttpContext httpContext) + { + try + { + var data = DeserializePayload.Deserialize(payload); + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + var feedList = await _repository.GetFeedByClientAsync(clientId); + + if (feedList == null || !feedList.Any()) + { + return new { success = false, message = "No feeds found", data = new List() }; + } + + var FeedListWithDoc = new List(); + + foreach (var feed in feedList) + { + object? documentData = null; + if (feed.FkDocId.HasValue) + { + try + { + var docPayload = new { documentId = feed.FkDocId.Value }; + documentData = await _documentService.GetDocumentById(docPayload, httpContext); + } + catch + { + documentData = null; + } + } + + var likeCount = await _repository.GetLikeCountAsync(feed.FeedPostId); + + FeedListWithDoc.Add(new + { + feedPostId = feed.FeedPostId, + caption = feed.Caption, + description = feed.Description, + userId = feed.FKUserId, + docId = feed.FkDocId, + isActive = feed.IsActive, + createdAt = feed.CreatedAt, + likeCount = likeCount, + document = documentData + }); + } + + return new { success = true, message = "Feeds retrieved successfully", data = FeedListWithDoc }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task DeleteFeedByPostId(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + + Guid createUserId = DataExtractor.GetUserIdFromContext(httpContext); + Guid postId = DataExtractor.GetRequiredGuid(data, "postId"); + await _uow.BeginAsync(); + + try + { + var result =await _repository.DeletePostPostIdAsync(createUserId, postId); + await _uow.CommitAsync(); + return result; + } + catch + { + await _uow.RollbackAsync(); + throw new BadRequestException ("delete post failed") ; + } + } + } +} + diff --git a/Services/FunctionHandler/AccessDeviceFunctionHandler.cs b/Services/FunctionHandler/AccessDeviceFunctionHandler.cs new file mode 100644 index 0000000..e4fe907 --- /dev/null +++ b/Services/FunctionHandler/AccessDeviceFunctionHandler.cs @@ -0,0 +1,128 @@ +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using Fitness_core_BE.Models.IOs; + +namespace Fitness_core_BE.Services.FunctionHandler +{ + public class AccessDeviceFunctionHandler + { + public readonly Dictionary>> _functionHandler; + + public AccessDeviceFunctionHandler(IAccessDeviceService accessDeviceService) + { + // Admin-auth actions only. The cloud no longer talks to devices directly: + // - AddDevice / RemoveDevice / GeneratePairingCode are pure DB operations. + // - AddUser / WhiteListMember / BlackListMember do their local DB work and + // ENQUEUE a DeviceCommand for the on-prem agent to execute. + // Device communication (attendance pull, command execution, enrollment) + // is handled by the agent against the AgentApiKey-protected endpoints in AgentController. + _functionHandler = new() + { + { "AddDevice", (payload, _) => accessDeviceService.AddDevice(payload) }, + { "RemoveDevice", (payload, _) => accessDeviceService.RemoveDevice(payload) }, + { "GeneratePairingCode", (payload, _) => accessDeviceService.GeneratePairingCode(payload) }, + { "AddUser", AddUser }, + { "WhiteListMember", WhiteListMember }, + { "BlackListMember", BlackListMember } + }; + + Task AddUser(object payload, HttpContext _) + { + var data = DeserializePayload.Deserialize(payload); + var customerAccess = new CustomerAccess + { + Name = DataExtractor.GetRequiredString(data, "Name"), + FKDeviceId = DataExtractor.GetRequiredGuid(data, "FKDeviceId"), + FKCustomerId = DataExtractor.GetRequiredGuid(data, "FKCustomerId") + }; + + return accessDeviceService.AddUser(customerAccess); + } + + Task WhiteListMember(object payload, HttpContext _) + { + var data = DeserializePayload.Deserialize(payload); + var customerId = DataExtractor.GetRequiredGuid(data, "customerId"); + return accessDeviceService.WhiteListMember(customerId).ContinueWith(t => (object)t.Result); + } + + Task BlackListMember(object payload, HttpContext _) + { + var data = DeserializePayload.Deserialize(payload); + var customerId = DataExtractor.GetRequiredGuid(data, "customerId"); + return accessDeviceService.BlackListMember(customerId).ContinueWith(t => (object)t.Result); + } + } + + public async Task> Execute(ApiRequest request, HttpContext httpContext) + { + if (!_functionHandler.TryGetValue(request.FunctionName, out var handler)) + { + return new ApiResponse + { + StatusCode = 404, + Message = $"Function '{request.FunctionName}' not found.", + Data = null! + }; + } + + try + { + var results = await handler(request.Payload, httpContext); + return new ApiResponse + { + StatusCode = 200, + Message = "success", + Data = results + }; + } + catch (UnauthorizedException ex) + { + return new ApiResponse + { + StatusCode = 401, + Message = ex.Message, + Data = null! + }; + } + catch (NotFoundException ex) + { + return new ApiResponse + { + StatusCode = 404, + Message = ex.Message, + Data = null! + }; + } + catch (BadRequestException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null! + }; + } + catch (ForbiddenException ex) + { + return new ApiResponse + { + StatusCode = 403, + Message = ex.Message, + Data = null! + }; + } + catch (Exception ex) + { + return new ApiResponse + { + StatusCode = 500, + Message = $"An error occurred: {ex.Message}", + Data = null! + }; + } + } + } +} \ No newline at end of file diff --git a/Services/FunctionHandler/ActivityLogFuncHandler.cs b/Services/FunctionHandler/ActivityLogFuncHandler.cs new file mode 100644 index 0000000..4cbd60e --- /dev/null +++ b/Services/FunctionHandler/ActivityLogFuncHandler.cs @@ -0,0 +1,25 @@ +using Fitness_core_BE.Services.ActivityLog; +using Fitness_core_BE.Interfaces.Services; + +namespace Fitness_core_BE.Services.FunctionHandler +{ + public class ActivityLogFuncHandler + { + private readonly IActivityLogService _activityLogService; + + public ActivityLogFuncHandler(IActivityLogService activityLogService) + { + _activityLogService = activityLogService; + } + + public async Task ExecuteAsync(string functionName, object payload, HttpContext httpContext) + { + return functionName switch + { + "LogActivity" => await _activityLogService.LogActivity(payload, httpContext), + "GetActivityLogs" => await _activityLogService.GetActivityLogs(payload, httpContext), + _ => throw new NotImplementedException($"Function '{functionName}' is not implemented.") + }; + } + } +} diff --git a/Services/FunctionHandler/AttendanceFunctionHandler.cs b/Services/FunctionHandler/AttendanceFunctionHandler.cs new file mode 100644 index 0000000..327107a --- /dev/null +++ b/Services/FunctionHandler/AttendanceFunctionHandler.cs @@ -0,0 +1,90 @@ +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models.IOs; + +namespace Fitness_core_BE.Services.FunctionHandler +{ + public class AttendanceFunctionHandler + { + private readonly Dictionary>> _functionHandler; + + public AttendanceFunctionHandler(IAttendanceService attendanceService) + { + _functionHandler = new() + { + { "GetAllAttendanceRecords", attendanceService.GetAllAttendanceRecords }, + { "GetAttendanceCounts", attendanceService.GetAttendanceCounts }, + { "GetTodayAttendance", attendanceService.GetTodayAttendance } + }; + } + + public async Task> Execute(ApiRequest request, HttpContext httpContext) + { + if (!_functionHandler.TryGetValue(request.FunctionName, out var handler)) + { + return new ApiResponse + { + StatusCode = 404, + Message = $"Function '{request.FunctionName}' not found.", + Data = null! + }; + } + + try + { + var results = await handler(request.Payload, httpContext); + return new ApiResponse + { + StatusCode = 200, + Message = "success", + Data = results + }; + } + catch (UnauthorizedException ex) + { + return new ApiResponse + { + StatusCode = 401, + Message = ex.Message, + Data = null! + }; + } + catch (NotFoundException ex) + { + return new ApiResponse + { + StatusCode = 404, + Message = ex.Message, + Data = null! + }; + } + catch (BadRequestException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null! + }; + } + catch (ForbiddenException ex) + { + return new ApiResponse + { + StatusCode = 403, + Message = ex.Message, + Data = null! + }; + } + catch (Exception ex) + { + return new ApiResponse + { + StatusCode = 500, + Message = $"An error occurred: {ex.Message}", + Data = null! + }; + } + } + } +} diff --git a/Services/FunctionHandler/AuthFuncHandler.cs b/Services/FunctionHandler/AuthFuncHandler.cs new file mode 100644 index 0000000..3350a94 --- /dev/null +++ b/Services/FunctionHandler/AuthFuncHandler.cs @@ -0,0 +1,100 @@ +using Fitness_core_BE.Models.IOs; +using Fitness_core_BE.Services.Auth; +using Fitness_core_BE.DTOs.Auth; +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Interfaces.Services; + +namespace Fitness_core_BE.Services.FunctionHandler +{ + public class AuthFuncHandler +{ + public readonly Dictionary>> _functionHandler; + + public AuthFuncHandler(IAuthService authService) + { + _functionHandler = new() + { + {"SendOtp", authService.SendOtp }, + {"VerifyOTP", authService.VerifyOTP }, + {"IsAvailable", authService.IsAvailable }, + {"RegisterUser", authService.RegisterUser }, + {"UpdateUser", authService.UpdateUser }, + {"GetUserDetails", authService.GetUserDetails }, + {"LoginUser", authService.LoginUser }, + {"RefreshToken", authService.RefreshToken }, + {"VerifyOtpForLogin", authService.VerifyOtpForLogin }, + {"forgotPassword", authService.ForgotPassword }, + {"resetPassword", authService.ResetPassword }, + {"resetPasswordWithToken", authService.ResetPasswordWithToken }, + {"LogOut", authService.LogOut } + + }; + } + + public async Task> Execute(ApiRequest request, HttpContext httpContext) + { + if (!_functionHandler.TryGetValue(request.FunctionName, out var handler)) + { + return new ApiResponse + { + StatusCode = 404, + Message = $"Function '{request.FunctionName}' not found.", + Data = null + }; + } + try + { + var authResponse = await handler(request.Payload, httpContext); + + return new ApiResponse + { + StatusCode = authResponse.StatusCode, + Message = authResponse.Message, + Data = authResponse.Data + }; + } + catch (UnauthorizedException ex) + { + return new ApiResponse + { + StatusCode = 401, + Message = ex.Message, + }; + } + catch (NotFoundException ex) + { + return new ApiResponse + { + StatusCode = 404, + Message = ex.Message, + }; + } + catch (BadRequestException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + }; + } + catch (ForbiddenException ex) + { + return new ApiResponse + { + StatusCode = 403, + Message = ex.Message, + }; + } + catch (Exception ex) + { + return new ApiResponse + { + StatusCode = 500, + Message = $"An error occurred: {ex.Message}", + }; + } + } + } +} + + diff --git a/Services/FunctionHandler/ChatFunctionHandler.cs b/Services/FunctionHandler/ChatFunctionHandler.cs new file mode 100644 index 0000000..ba8e7ca --- /dev/null +++ b/Services/FunctionHandler/ChatFunctionHandler.cs @@ -0,0 +1,81 @@ +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models.IOs; +using Fitness_core_BE.Services; + +namespace Fitness_core_BE.Services.FunctionHandler +{ + public class ChatFunctionHandler + { + public readonly Dictionary>> _functionHandler; + + public ChatFunctionHandler(IChatService chatService) + { + _functionHandler = new() + { + { "GetOrCreateChatAsync", chatService.GetOrCreateChatAsync }, + { "GetClientById", chatService.SendMessageAsync }, + { "RegisterClient", chatService.GetMessagesAsync } + }; + } + + public async Task> Execute(ApiRequest request) + { + if (!_functionHandler.TryGetValue(request.FunctionName, out var handler)) + { + return new ApiResponse + { + StatusCode = 404, + Message = $"Function '{request.FunctionName}' not found.", + Data = null + }; + } + try + { + var results = await handler(request.Payload); + return new ApiResponse + { + StatusCode = 200, + Message = "success", + Data = results + }; + } + catch (UnauthorizedAccessException ex) + { + return new ApiResponse + { + StatusCode = 401, + Message = ex.Message, + Data = null + }; + } + catch (KeyNotFoundException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null + }; + } + catch (InvalidOperationException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null + }; + } + catch (Exception ex) + { + return new ApiResponse + { + StatusCode = 500, + Message = $"An error occurred: {ex.Message}", + Data = null + }; + } + } + } +} + diff --git a/Services/FunctionHandler/ClientFuncHandler.cs b/Services/FunctionHandler/ClientFuncHandler.cs new file mode 100644 index 0000000..3f1e6df --- /dev/null +++ b/Services/FunctionHandler/ClientFuncHandler.cs @@ -0,0 +1,106 @@ +using Fitness_core_BE.Models.IOs; +using Fitness_core_BE.Services; +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Interfaces.Services; + +namespace Fitness_core_BE.Services.FunctionHandler +{ + public class ClientFuncHandler + { + public readonly Dictionary>> _functionHandler; + + public ClientFuncHandler(IClientService clientService) + { + _functionHandler = new() + { + { "GetClientList", clientService.GetClientList }, + { "GetClientById", clientService.GetClientById }, + { "GetCustomerDetailsById", clientService.GetCustomerDetailsById }, + { "CreateCustomerWithPlans", clientService.CreateCustomerWithPlans }, + { "UpdateCustomerByClient", clientService.UpdateCustomerByClient }, + {"GetCustomersByClientId", clientService.GetCustomersByClientId}, + {"GetCustomerPlansByClientId", clientService.GetCustomerPlansByClientId}, + {"RegisterClient", clientService.RegisterClient}, + { "GetPackageList", clientService.GetPackageList }, + {"GetAllCustomers", clientService.GetAllCustomers }, + {"DeleteCustomerByClient", clientService.DeleteCustomerByClient }, + {"ClientLogOut", clientService.ClientLogOut }, + {"GetAllCustomerPlans", clientService.GetAllCustomerPlans }, + {"GetMealPlansByClientId", clientService.GetMealPlansByClientId }, + {"GetWorkoutPlansByClientId", clientService.GetWorkoutPlansByClientId }, + {"GetMealPlanById", clientService.GetMealPlanById }, + {"GetWorkoutPlanById", clientService.GetWorkoutPlanById }, + {"GetCustomerPlanById", clientService.GetCustomerPlanById }, + {"GetOwnCards", clientService.GetCardDetails }, + }; + } + + public async Task> Execute(ApiRequest request, HttpContext httpContext) + { + if (!_functionHandler.TryGetValue(request.FunctionName, out var handler)) + { + return new ApiResponse + { + StatusCode = 404, + Message = $"Function '{request.FunctionName}' not found.", + Data = null + }; + } + try + { + var results = await handler(request.Payload, httpContext); + return new ApiResponse + { + StatusCode = 200, + Message = "success", + Data = results + }; + } + catch (UnauthorizedException ex) + { + return new ApiResponse + { + StatusCode = 401, + Message = ex.Message, + Data = null + }; + } + catch (NotFoundException ex) + { + return new ApiResponse + { + StatusCode = 404, + Message = ex.Message, + Data = null + }; + } + catch (BadRequestException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null + }; + } + catch (ForbiddenException ex) + { + return new ApiResponse + { + StatusCode = 403, + Message = ex.Message, + Data = null + }; + } + catch (Exception ex) + { + return new ApiResponse + { + StatusCode = 500, + Message = $"An error occurred: {ex.Message}", + Data = null + }; + } + } + } +} diff --git a/Services/FunctionHandler/ClientInvoiceFunctionHandler.cs b/Services/FunctionHandler/ClientInvoiceFunctionHandler.cs new file mode 100644 index 0000000..66494da --- /dev/null +++ b/Services/FunctionHandler/ClientInvoiceFunctionHandler.cs @@ -0,0 +1,83 @@ +using Fitness_core_BE.Models.IOs; +using Fitness_core_BE.Services.ClientInvoice; +using Fitness_core_BE.Interfaces.Services; + +namespace Fitness_core_BE.Services.FunctionHandler +{ + public class ClientInvoiceFunctionHandler + { + public readonly Dictionary>> _functionHandler; + + public ClientInvoiceFunctionHandler(IClientInvoiceService clientInvoiceService) + { + _functionHandler = new() + { + { "CreateClientInvoiceFromSubscription", clientInvoiceService.CreateClientInvoiceFromSubscription }, + { "GetClientInvoiceById", clientInvoiceService.GetClientInvoiceById }, + { "GetClientInvoicesByClientId", clientInvoiceService.GetClientInvoicesByClientId }, + { "GetClientInvoicesByUserId", clientInvoiceService.GetClientInvoicesByUserId }, + { "UpdateClientInvoiceStatus", clientInvoiceService.UpdateClientInvoiceStatus }, + { "CalculateClientInvoiceTotal", clientInvoiceService.CalculateClientInvoiceTotal } + }; + } + + public async Task> Execute(ApiRequest request, HttpContext httpContext) + { + if (!_functionHandler.TryGetValue(request.FunctionName, out var handler)) + { + return new ApiResponse + { + StatusCode = 404, + Message = $"Function '{request.FunctionName}' not found.", + Data = null! + }; + } + try + { + var results = await handler(request.Payload, httpContext); + return new ApiResponse + { + StatusCode = 200, + Message = "success", + Data = results + }; + } + catch (UnauthorizedAccessException ex) + { + return new ApiResponse + { + StatusCode = 401, + Message = ex.Message, + Data = null! + }; + } + catch (KeyNotFoundException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null! + }; + } + catch (InvalidOperationException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null! + }; + } + catch (Exception ex) + { + return new ApiResponse + { + StatusCode = 500, + Message = $"An error occurred: {ex.Message}", + Data = null! + }; + } + } + } +} diff --git a/Services/FunctionHandler/ClientStatFuncHandler.cs b/Services/FunctionHandler/ClientStatFuncHandler.cs new file mode 100644 index 0000000..eeacce5 --- /dev/null +++ b/Services/FunctionHandler/ClientStatFuncHandler.cs @@ -0,0 +1,77 @@ +using Fitness_core_BE.Models.IOs; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Exceptions; + +namespace Fitness_core_BE.Services.FunctionHandler +{ + public class ClientStatFuncHandler + { + public readonly Dictionary>> _functionHandler; + + public ClientStatFuncHandler(IClientStatService clientStatService) + { + _functionHandler = new() + { + { "GetTotalCustomerCount", clientStatService.GetTotalCustomerCount }, + { "GetMonthlyCustomerCount", clientStatService.GetMonthlyCustomerCount }, + { "GetTotalWorkoutPlanCount", clientStatService.GetTotalWorkoutPlanCount }, + { "GetTotalMealPlanCount", clientStatService.GetTotalMealPlanCount }, + { "GetLastMonthCustomerCount", clientStatService.GetLastMonthCustomerCount }, + { "GetLastFiveAttendances", clientStatService.GetLastFiveAttendances }, + { "GetLastFivePayments", clientStatService.GetLastFivePayments }, + { "GetLastMonthRevenue", clientStatService.GetLastMonthRevenue }, + { "GetMonthlyRevenueVariation", (payload, ctx) => clientStatService.GetMonthlyRevenueVariation(payload, ctx) }, + { "GetTotalRevenue", clientStatService.GetTotalRevenue }, + { "GetClientStatSummary", (payload, ctx) => clientStatService.GetClientStatSummary(payload, ctx) } + }; + } + + public async Task> Execute(ApiRequest request, HttpContext httpContext) + { + if (!_functionHandler.TryGetValue(request.FunctionName, out var handler)) + { + return new ApiResponse + { + StatusCode = 404, + Message = $"Function '{request.FunctionName}' not found.", + Data = null + }; + } + try + { + var results = await handler(request.Payload, httpContext); + return new ApiResponse + { + StatusCode = 200, + Message = "success", + Data = results + }; + } + catch (UnauthorizedException ex) + { + return new ApiResponse { StatusCode = 401, Message = ex.Message, Data = null }; + } + catch (NotFoundException ex) + { + return new ApiResponse { StatusCode = 404, Message = ex.Message, Data = null }; + } + catch (BadRequestException ex) + { + return new ApiResponse { StatusCode = 400, Message = ex.Message, Data = null }; + } + catch (ForbiddenException ex) + { + return new ApiResponse { StatusCode = 403, Message = ex.Message, Data = null }; + } + catch (Exception ex) + { + return new ApiResponse + { + StatusCode = 500, + Message = $"An error occurred: {ex.Message}", + Data = null + }; + } + } + } +} diff --git a/Services/FunctionHandler/CustomerFunctionHandler.cs b/Services/FunctionHandler/CustomerFunctionHandler.cs new file mode 100644 index 0000000..87b7914 --- /dev/null +++ b/Services/FunctionHandler/CustomerFunctionHandler.cs @@ -0,0 +1,104 @@ +using Fitness_core_BE.Models.IOs; +using Fitness_core_BE.Services.CustomerService; +using Fitness_core_BE.Interfaces.Services; + +namespace Fitness_core_BE.Services.FunctionHandler +{ + public class CustomerFunctionHandler + { + public readonly Dictionary>> _functionHandler; + + public CustomerFunctionHandler(ICustomerService customerService) + { + _functionHandler = new() + { + { "CreateCustomer", customerService.CreateCustomer }, + { "DeleteCustomer", customerService.DeleteCustomer }, + { "UpdateCustomer", customerService.UpdateCustomer }, + { "GetCustomerMealPlan", customerService.GetCustomerMealPlan }, + { "GetCustomerMealPlanById", customerService.GetCustomerMealPlanById }, + { "GetCustomerWorkOutPlan", customerService.GetCustomerWorkOutPlan }, + { "GetCustomerWorkoutPlanById", customerService.GetCustomerWorkoutPlanById }, + { "FollowMealPlan", customerService.FollowMealPlan }, + { "GetFollowedMealPlans", customerService.GetFollowedMealPlans }, + { "GetFollowedWorkoutPlans", customerService.GetFollowedWorkoutPlans }, + { "FollowWorkOutPlan", customerService.FollowWorkOutPlan }, + { "GetClientDataById", customerService.GetClientDataById }, + { "CreateCusHealthRec", customerService.CreateCusHealthRec }, + { "CreateCusProgressRec", customerService.CreateCusProgressRec }, + { "UpdateCusHealthRec", customerService.UpdateCusHealthRec }, + { "UpdateCusProgressRec", customerService.UpdateCusProgressRec }, + { "UpdateCustomerTargetNutritions", customerService.UpdateCustomerTargetNutritions }, + { "GetClientGyms", customerService.GetClientGyms }, + { "GetCustomerProgress", customerService.GetCustomerProgress }, + { "UpdateWeeklyProgress", customerService.UpdateWeeklyProgress }, + { "GetWeeklyProgressForCustomer", customerService.GetWeeklyProgressForCustomer }, + { "GetCusBasicDetails", customerService.GetCusBasicDetails }, + { "GetCusHealthDetails", customerService.GetCusHealthDetails }, + { "GetCusProgressDetails", customerService.GetCusProgressDetails }, + {"CreateFeed", customerService.CreateFeed }, + {"GetOwnCards", customerService.GetCardDetails } + + }; + } + + public async Task> Execute(ApiRequest request, HttpContext httpContext) + { + if (!_functionHandler.TryGetValue(request.FunctionName, out var handler)) + { + return new ApiResponse + { + StatusCode = 404, + Message = $"Function '{request.FunctionName}' not found.", + Data = null! + }; + } + try + { + var results = await handler(request.Payload, httpContext); + return new ApiResponse + { + StatusCode = 200, + Message = "success", + Data = results + }; + } + catch (UnauthorizedAccessException ex) + { + return new ApiResponse + { + StatusCode = 401, + Message = ex.Message, + Data = null! + }; + } + catch (KeyNotFoundException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null! + }; + } + catch (InvalidOperationException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null! + }; + } + catch (Exception ex) + { + return new ApiResponse + { + StatusCode = 500, + Message = $"An error occurred: {ex.Message}", + Data = null! + }; + } + } + } +} diff --git a/Services/FunctionHandler/CustomerInvoiceFunctionHandler.cs b/Services/FunctionHandler/CustomerInvoiceFunctionHandler.cs new file mode 100644 index 0000000..b903a02 --- /dev/null +++ b/Services/FunctionHandler/CustomerInvoiceFunctionHandler.cs @@ -0,0 +1,83 @@ +using Fitness_core_BE.Models.IOs; +using Fitness_core_BE.Services.CustomerInvoice; +using Fitness_core_BE.Interfaces.Services; + +namespace Fitness_core_BE.Services.FunctionHandler +{ + public class CustomerInvoiceFunctionHandler + { + public readonly Dictionary>> _functionHandler; + + public CustomerInvoiceFunctionHandler(ICustomerInvoiceService customerInvoiceService) + { + _functionHandler = new() + { + { "CreateCustomerInvoiceFromSubscription", customerInvoiceService.CreateCustomerInvoiceFromSubscription }, + { "GetCustomerInvoiceById", customerInvoiceService.GetCustomerInvoiceById }, + { "GetCustomerInvoicesByCustomerId", customerInvoiceService.GetCustomerInvoicesByCustomerId }, + { "GetCustomerInvoicesByClientId", customerInvoiceService.GetCustomerInvoicesByClientId }, + { "UpdateCustomerInvoiceStatus", customerInvoiceService.UpdateCustomerInvoiceStatus }, + { "CalculateCustomerInvoiceTotal", customerInvoiceService.CalculateCustomerInvoiceTotal } + }; + } + + public async Task> Execute(ApiRequest request, HttpContext httpContext) + { + if (!_functionHandler.TryGetValue(request.FunctionName, out var handler)) + { + return new ApiResponse + { + StatusCode = 404, + Message = $"Function '{request.FunctionName}' not found.", + Data = null! + }; + } + try + { + var results = await handler(request.Payload, httpContext); + return new ApiResponse + { + StatusCode = 200, + Message = "success", + Data = results + }; + } + catch (UnauthorizedAccessException ex) + { + return new ApiResponse + { + StatusCode = 401, + Message = ex.Message, + Data = null! + }; + } + catch (KeyNotFoundException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null! + }; + } + catch (InvalidOperationException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null! + }; + } + catch (Exception ex) + { + return new ApiResponse + { + StatusCode = 500, + Message = $"An error occurred: {ex.Message}", + Data = null! + }; + } + } + } +} diff --git a/Services/FunctionHandler/CustomerPlanFunctionHandler.cs b/Services/FunctionHandler/CustomerPlanFunctionHandler.cs new file mode 100644 index 0000000..14708f8 --- /dev/null +++ b/Services/FunctionHandler/CustomerPlanFunctionHandler.cs @@ -0,0 +1,83 @@ +using Fitness_core_BE.Models.IOs; +using Fitness_core_BE.Services.CustomerPlanService; +using Fitness_core_BE.Interfaces.Services; + +namespace Fitness_core_BE.Services.FunctionHandler +{ + public class CustomerPlanFunctionHandler + { + public readonly Dictionary>> _functionHandler; + + public CustomerPlanFunctionHandler(ICustomerPlanService customerPlanService) + { + _functionHandler = new() + { + { "GetAllCustomerPlans", customerPlanService.GetAllCustomerPlans }, + { "GetCustomerPlanById", customerPlanService.GetCustomerPlanById }, + { "CreateCustomerPlan", customerPlanService.CreateCustomerPlan }, + { "UpdateCustomerPlan", customerPlanService.UpdateCustomerPlan }, + { "SetCustomerPlanStatus", customerPlanService.SetCustomerPlanStatus }, + { "DeleteCustomerPlan", customerPlanService.DeleteCustomerPlan } + }; + } + + public async Task> Execute(ApiRequest request, HttpContext httpContext) + { + if (!_functionHandler.TryGetValue(request.FunctionName, out var handler)) + { + return new ApiResponse + { + StatusCode = 404, + Message = $"Function '{request.FunctionName}' not found.", + Data = null! + }; + } + try + { + var results = await handler(request.Payload, httpContext); + return new ApiResponse + { + StatusCode = 200, + Message = "success", + Data = results + }; + } + catch (UnauthorizedAccessException ex) + { + return new ApiResponse + { + StatusCode = 401, + Message = ex.Message, + Data = null! + }; + } + catch (KeyNotFoundException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null! + }; + } + catch (InvalidOperationException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null! + }; + } + catch (Exception ex) + { + return new ApiResponse + { + StatusCode = 500, + Message = $"An error occurred: {ex.Message}", + Data = null! + }; + } + } + } +} diff --git a/Services/FunctionHandler/CustomerSubscriptionFunctionHandler.cs b/Services/FunctionHandler/CustomerSubscriptionFunctionHandler.cs new file mode 100644 index 0000000..5162a07 --- /dev/null +++ b/Services/FunctionHandler/CustomerSubscriptionFunctionHandler.cs @@ -0,0 +1,81 @@ +using Fitness_core_BE.Models.IOs; +using Fitness_core_BE.Services.CustomerSubscription; +using Fitness_core_BE.Interfaces.Services; + +namespace Fitness_core_BE.Services.FunctionHandler +{ + public class CustomerSubscriptionFunctionHandler + { + public readonly Dictionary>> _functionHandler; + + public CustomerSubscriptionFunctionHandler(ICustomerSubscriptionService customerSubscriptionService) + { + _functionHandler = new() + { + { "CreateCustomerSubscription", customerSubscriptionService.CreateCustomerSubscription }, + { "GetCustomerSubscriptionById", customerSubscriptionService.GetCustomerSubscriptionById }, + { "GetCustomerSubscriptionsByCustomerId", customerSubscriptionService.GetCustomerSubscriptionsByCustomerId }, + { "GetActiveSubscriptionByCustomerId", customerSubscriptionService.GetActiveSubscriptionByCustomerId } + }; + } + + public async Task> Execute(ApiRequest request, HttpContext httpContext) + { + if (!_functionHandler.TryGetValue(request.FunctionName, out var handler)) + { + return new ApiResponse + { + StatusCode = 404, + Message = $"Function '{request.FunctionName}' not found.", + Data = null! + }; + } + try + { + var results = await handler(request.Payload, httpContext); + return new ApiResponse + { + StatusCode = 200, + Message = "success", + Data = results + }; + } + catch (UnauthorizedAccessException ex) + { + return new ApiResponse + { + StatusCode = 401, + Message = ex.Message, + Data = null! + }; + } + catch (KeyNotFoundException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null! + }; + } + catch (InvalidOperationException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null! + }; + } + catch (Exception ex) + { + return new ApiResponse + { + StatusCode = 500, + Message = $"An error occurred: {ex.Message}", + Data = null! + }; + } + } + } +} diff --git a/Services/FunctionHandler/DocumentFunctionHandler.cs b/Services/FunctionHandler/DocumentFunctionHandler.cs new file mode 100644 index 0000000..9e9721d --- /dev/null +++ b/Services/FunctionHandler/DocumentFunctionHandler.cs @@ -0,0 +1,90 @@ +using Fitness_core_BE.Models.IOs; +using Fitness_core_BE.Services.Document; +using Fitness_core_BE.Interfaces.Services; + +namespace Fitness_core_BE.Services.FunctionHandler +{ + public class DocumentFunctionHandler + { + public readonly Dictionary>> _functionHandler; + + public DocumentFunctionHandler(IDocumentService documentService) + { + _functionHandler = new() + { + { "UploadDocument", documentService.UploadDocument }, + { "GetDocumentsByUserId", documentService.GetDocumentsByUserId }, + { "GetDocumentById", documentService.GetDocumentById }, + { "RemoveDocument", documentService.RemoveDocument } + }; + } + + public async Task> Execute(ApiRequest request, HttpContext httpContext) + { + if (!_functionHandler.TryGetValue(request.FunctionName, out var handler)) + { + return new ApiResponse + { + StatusCode = 404, + Message = $"Function '{request.FunctionName}' not found.", + Data = null! + }; + } + try + { + var results = await handler(request.Payload, httpContext); + return new ApiResponse + { + StatusCode = 200, + Message = "success", + Data = results + }; + } + catch (UnauthorizedAccessException ex) + { + return new ApiResponse + { + StatusCode = 401, + Message = ex.Message, + Data = null! + }; + } + catch (KeyNotFoundException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null! + }; + } + catch (InvalidOperationException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null! + }; + } + catch (FileNotFoundException ex) + { + return new ApiResponse + { + StatusCode = 404, + Message = ex.Message, + Data = null! + }; + } + catch (Exception ex) + { + return new ApiResponse + { + StatusCode = 500, + Message = $"An error occurred: {ex.Message}", + Data = null! + }; + } + } + } +} diff --git a/Services/FunctionHandler/FeedFuncHandler.cs b/Services/FunctionHandler/FeedFuncHandler.cs new file mode 100644 index 0000000..1afe4b7 --- /dev/null +++ b/Services/FunctionHandler/FeedFuncHandler.cs @@ -0,0 +1,96 @@ +using Fitness_core_BE.Models.IOs; +using Fitness_core_BE.Services.Feed; +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Interfaces.Services; + +namespace Fitness_core_BE.Services.FunctionHandler +{ + public class FeedFuncHandler +{ + public readonly Dictionary>> _functionHandler; + + public FeedFuncHandler(IFeedService feedService) + { + _functionHandler = new() + { + { "GetFeedPostById", feedService.GetFeedPostById }, + { "GetFeedList", feedService.GetFeedForCustomer }, + { "Like", feedService.LikeAsync }, + { "Unlike", feedService.UnlikeAsync }, + { "IsLiked", feedService.IsLikedAsync }, + { "CreateFeed", feedService.CreateFeed }, + { "ShowFeedByClientId", feedService.ShowFeedByClient }, + { "DeleteFeedByPostId", feedService.DeleteFeedByPostId } + }; + + } + + public async Task> Execute(ApiRequest request, HttpContext httpContext) + { + if (!_functionHandler.TryGetValue(request.FunctionName, out var handler)) + { + return new ApiResponse + { + StatusCode = 404, + Message = $"Function '{request.FunctionName}' not found.", + Data = null + }; + } + try + { + var results = await handler(request.Payload, httpContext); + return new ApiResponse + { + StatusCode = 200, + Message = "success", + Data = results + }; + } + catch (UnauthorizedException ex) + { + return new ApiResponse + { + StatusCode = 401, + Message = ex.Message, + Data = null + }; + } + catch (NotFoundException ex) + { + return new ApiResponse + { + StatusCode = 404, + Message = ex.Message, + Data = null + }; + } + catch (BadRequestException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null + }; + } + catch (ForbiddenException ex) + { + return new ApiResponse + { + StatusCode = 403, + Message = ex.Message, + Data = null + }; + } + catch (Exception ex) + { + return new ApiResponse + { + StatusCode = 500, + Message = $"An error occurred: {ex.Message}", + Data = null + }; + } + } + } +} diff --git a/Services/FunctionHandler/MealFunctionHandler.cs b/Services/FunctionHandler/MealFunctionHandler.cs new file mode 100644 index 0000000..b734aaf --- /dev/null +++ b/Services/FunctionHandler/MealFunctionHandler.cs @@ -0,0 +1,88 @@ +using Fitness_core_BE.Models.IOs; +using Fitness_core_BE.Services.Meal; +using Fitness_core_BE.Interfaces.Services; + +namespace Fitness_core_BE.Services.FunctionHandler +{ + public class MealFunctionHandler + { + public readonly Dictionary>> _functionHandler; + + public MealFunctionHandler(IMealService mealService) + { + _functionHandler = new() + { + { "GetMealCategoryList", mealService.GetMealCategoryList }, + { "CreateMealCategory", mealService.CreateMealCategory }, + { "GetMealPlanList", mealService.GetMealPlanList }, + { "GetMealPlanById", mealService.GetMealPlanById }, + { "GetMealPlanByCustomerId", mealService.GetMealPlanByCustomerId }, + { "CreateMealPlan", mealService.CreateMealPlan }, + { "CreateCustomerMealPlan", mealService.CreateCustomerMealPlan }, + { "UpdateMealPlan", mealService.UpdateMealPlan }, + { "GetMealItemsByMealPlanId", mealService.GetMealItemsByMealPlanId }, + { "CreateMealItem", mealService.CreateMealItem }, + { "DeleteMealPlan", mealService.DeleteMealPlan } + }; + } + + public async Task> Execute(ApiRequest request, HttpContext httpContext) + { + if (!_functionHandler.TryGetValue(request.FunctionName, out var handler)) + { + return new ApiResponse + { + StatusCode = 404, + Message = $"Function '{request.FunctionName}' not found.", + Data = null + }; + } + try + { + var results = await handler(request.Payload, httpContext); + return new ApiResponse + { + StatusCode = 200, + Message = "success", + Data = results + }; + } + catch (UnauthorizedAccessException ex) + { + return new ApiResponse + { + StatusCode = 401, + Message = ex.Message, + Data = null + }; + } + catch (KeyNotFoundException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null + }; + } + catch (InvalidOperationException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null + }; + } + catch (Exception ex) + { + return new ApiResponse + { + StatusCode = 500, + Message = $"An error occurred: {ex.Message}", + Data = null + }; + } + } + } +} diff --git a/Services/FunctionHandler/PaymentFunctionHandler.cs b/Services/FunctionHandler/PaymentFunctionHandler.cs new file mode 100644 index 0000000..e1ec748 --- /dev/null +++ b/Services/FunctionHandler/PaymentFunctionHandler.cs @@ -0,0 +1,83 @@ +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models.IOs; + +namespace Fitness_core_BE.Services.FunctionHandler +{ + public class PaymentFunctionHandler + { + public readonly Dictionary>> _functionHandler; + + public PaymentFunctionHandler(IPaymentService paymentService) + { + _functionHandler = new() + { + { "CreateCustomerPayment", paymentService.CreateCustomerPayment }, + { "CreateClientPayment", paymentService.CreateClientPayment }, + { "GetPaymentById", paymentService.GetPaymentById }, + { "GetPaymentsByCustomerInvoice", paymentService.GetPaymentsByCustomerInvoice }, + { "GetPaymentsByClientInvoice", paymentService.GetPaymentsByClientInvoice }, + { "ApprovePayment", paymentService.ApprovePayment }, + }; + } + + public async Task> Execute(ApiRequest request, HttpContext httpContext) + { + if (!_functionHandler.TryGetValue(request.FunctionName, out var handler)) + { + return new ApiResponse + { + StatusCode = 404, + Message = $"Function '{request.FunctionName}' not found.", + Data = null! + }; + } + try + { + var results = await handler(request.Payload, httpContext); + return new ApiResponse + { + StatusCode = 200, + Message = "success", + Data = results + }; + } + catch (NotFoundException ex) + { + return new ApiResponse + { + StatusCode = 404, + Message = ex.Message, + Data = null! + }; + } + catch (BadRequestException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null! + }; + } + catch (UnauthorizedException ex) + { + return new ApiResponse + { + StatusCode = 401, + Message = ex.Message, + Data = null! + }; + } + catch (Exception ex) + { + return new ApiResponse + { + StatusCode = 500, + Message = $"An error occurred: {ex.Message}", + Data = null! + }; + } + } + } +} diff --git a/Services/FunctionHandler/StaffFunctionHandler.cs b/Services/FunctionHandler/StaffFunctionHandler.cs new file mode 100644 index 0000000..7ccae19 --- /dev/null +++ b/Services/FunctionHandler/StaffFunctionHandler.cs @@ -0,0 +1,83 @@ +using Fitness_core_BE.Models.IOs; +using Fitness_core_BE.Services.Staff; +using Fitness_core_BE.Interfaces.Services; + +namespace Fitness_core_BE.Services.FunctionHandler +{ + public class StaffFunctionHandler + { + public readonly Dictionary>> _functionHandler; + + public StaffFunctionHandler(IStaffService staffService) + { + _functionHandler = new() + { + { "GetAllStaff", staffService.GetAllStaff }, + { "GetStaffById", staffService.GetStaffById }, + { "GetStaffByClientId", staffService.GetStaffByClientId }, + { "CreateStaff", staffService.CreateStaff }, + { "UpdateStaff", staffService.UpdateStaff }, + { "DeleteStaff", staffService.DeleteStaff } + }; + } + + public async Task> Execute(ApiRequest request, HttpContext httpContext) + { + if (!_functionHandler.TryGetValue(request.FunctionName, out var handler)) + { + return new ApiResponse + { + StatusCode = 404, + Message = $"Function '{request.FunctionName}' not found.", + Data = null! + }; + } + try + { + var results = await handler(request.Payload, httpContext); + return new ApiResponse + { + StatusCode = 200, + Message = "success", + Data = results + }; + } + catch (UnauthorizedAccessException ex) + { + return new ApiResponse + { + StatusCode = 401, + Message = ex.Message, + Data = null! + }; + } + catch (KeyNotFoundException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null! + }; + } + catch (InvalidOperationException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null! + }; + } + catch (Exception ex) + { + return new ApiResponse + { + StatusCode = 500, + Message = $"An error occurred: {ex.Message}", + Data = null! + }; + } + } + } +} diff --git a/Services/FunctionHandler/TicketFunctionhandler.cs b/Services/FunctionHandler/TicketFunctionhandler.cs new file mode 100644 index 0000000..eb58b6a --- /dev/null +++ b/Services/FunctionHandler/TicketFunctionhandler.cs @@ -0,0 +1,87 @@ +using Fitness_core_BE.Models.IOs; +using Fitness_core_BE.Services.Ticket; +using Fitness_core_BE.Interfaces.Services; + +namespace Fitness_core_BE.Services.FunctionHandler +{ + public class TicketFuncHandler + { + public readonly Dictionary>> _functionHandler; + + public TicketFuncHandler(ITicketService ticketService) + { + _functionHandler = new() + { + { "GetTicketList", ticketService.GetTicketList }, + { "GetTicketById", ticketService.GetTicketById }, + { "GetTicketByUserId", ticketService.GetTicketByUserId }, + { "GetTicketCategoryList", ticketService.GetTicketCategoryList }, + { "CreateTicket", ticketService.CreateTicket }, + { "UpdateTicket", ticketService.UpdateTicket }, + { "SendMessage", ticketService.SendMessage }, + { "SubmitTicketFeedback", ticketService.SubmitTicketFeedback }, + { "CloseTicket", ticketService.CloseTicket }, + { "DeleteTicket", ticketService.DeleteTicket } + }; + } + + public async Task> Execute(ApiRequest request, HttpContext httpContext) + { + if (!_functionHandler.TryGetValue(request.FunctionName, out var handler)) + { + return new ApiResponse + { + StatusCode = 404, + Message = $"Function '{request.FunctionName}' not found.", + Data = null + }; + } + try + { + var results = await handler(request.Payload, httpContext); + return new ApiResponse + { + StatusCode = 200, + Message = "success", + Data = results + }; + } + catch (UnauthorizedAccessException ex) + { + return new ApiResponse + { + StatusCode = 401, + Message = ex.Message, + Data = null + }; + } + catch (KeyNotFoundException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null + }; + } + catch (InvalidOperationException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null + }; + } + catch (Exception ex) + { + return new ApiResponse + { + StatusCode = 500, + Message = $"An error occurred: {ex.Message}", + Data = null + }; + } + } + } +} diff --git a/Services/FunctionHandler/UserRegisterHandler.cs b/Services/FunctionHandler/UserRegisterHandler.cs new file mode 100644 index 0000000..13fd4ac --- /dev/null +++ b/Services/FunctionHandler/UserRegisterHandler.cs @@ -0,0 +1,78 @@ +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models.IOs; + +namespace Fitness_core_BE.Services.FunctionHandler +{ + public class UserRegisterHandler + { + public readonly Dictionary>> _functionHandler; + + public UserRegisterHandler(ICustomerService customerService, IClientService clientService) + { + _functionHandler = new() + { + { "CreateCustomer", customerService.CreateCustomer }, + {"RegisterClient", clientService.RegisterClient } + }; + } + + public async Task> Execute(ApiRequest request, HttpContext httpContext) + { + if (!_functionHandler.TryGetValue(request.FunctionName, out var handler)) + { + return new ApiResponse + { + StatusCode = 404, + Message = $"Function '{request.FunctionName}' not found.", + Data = null + }; + } + try + { + var results = await handler(request.Payload, httpContext); + return new ApiResponse + { + StatusCode = 200, + Message = "success", + Data = results + }; + } + catch (UnauthorizedAccessException ex) + { + return new ApiResponse + { + StatusCode = 401, + Message = ex.Message, + Data = null + }; + } + catch (KeyNotFoundException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null + }; + } + catch (InvalidOperationException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null + }; + } + catch (Exception ex) + { + return new ApiResponse + { + StatusCode = 500, + Message = $"An error occurred: {ex.Message}", + Data = null + }; + } + } + } +} diff --git a/Services/FunctionHandler/WorkoutFunctionHandler.cs b/Services/FunctionHandler/WorkoutFunctionHandler.cs new file mode 100644 index 0000000..e07c728 --- /dev/null +++ b/Services/FunctionHandler/WorkoutFunctionHandler.cs @@ -0,0 +1,88 @@ +using Fitness_core_BE.Models.IOs; +using Fitness_core_BE.Services.Workout; +using Fitness_core_BE.Interfaces.Services; + +namespace Fitness_core_BE.Services.FunctionHandler +{ + public class WorkoutFunctionHandler + { + public readonly Dictionary>> _functionHandler; + + public WorkoutFunctionHandler(IWorkoutService workoutService) + { + _functionHandler = new() + { + { "GetWorkoutCategoryList", workoutService.GetWorkoutCategoryList }, + { "CreateWorkoutCategory", workoutService.CreateWorkoutCategory }, + { "GetWorkoutPlanList", workoutService.GetWorkoutPlanList }, + { "GetWorkoutPlanById", workoutService.GetWorkoutPlanById }, + { "GetWorkoutPlanByCustomerId", workoutService.GetWorkoutPlanByCustomerId }, + { "CreateWorkoutPlan", workoutService.CreateWorkoutPlan }, + { "CreateCustomerWorkout", workoutService.CreateCustomerWorkout }, + { "UpdateWorkoutPlan", workoutService.UpdateWorkoutPlan }, + { "DeleteWorkoutPlan", workoutService.DeleteWorkoutPlan }, + { "GetWorkoutItemsByWorkoutPlanId", workoutService.GetWorkoutItemsByWorkoutPlanId }, + { "CreateWorkoutItem", workoutService.CreateWorkoutItem } + }; + } + + public async Task> Execute(ApiRequest request, HttpContext httpContext) + { + if (!_functionHandler.TryGetValue(request.FunctionName, out var handler)) + { + return new ApiResponse + { + StatusCode = 404, + Message = $"Function '{request.FunctionName}' not found.", + Data = null + }; + } + try + { + var results = await handler(request.Payload, httpContext); + return new ApiResponse + { + StatusCode = 200, + Message = "success", + Data = results + }; + } + catch (UnauthorizedAccessException ex) + { + return new ApiResponse + { + StatusCode = 401, + Message = ex.Message, + Data = null + }; + } + catch (KeyNotFoundException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null + }; + } + catch (InvalidOperationException ex) + { + return new ApiResponse + { + StatusCode = 400, + Message = ex.Message, + Data = null + }; + } + catch (Exception ex) + { + return new ApiResponse + { + StatusCode = 500, + Message = $"An error occurred: {ex.Message}", + Data = null + }; + } + } + } +} diff --git a/Services/GymRoleService.cs b/Services/GymRoleService.cs new file mode 100644 index 0000000..4f96a85 --- /dev/null +++ b/Services/GymRoleService.cs @@ -0,0 +1,126 @@ +using Fitness_core_BE.DTOs.Rbac; +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Services +{ + public class GymRoleService : IGymRoleService + { + private readonly IGymRoleRepository _gymRoleRepository; + private readonly INavItemRepository _navItemRepository; + private readonly UserRoleHelper _userRoleHelper; + private readonly IUnitOfWork _uow; + + public GymRoleService( + IGymRoleRepository gymRoleRepository, + INavItemRepository navItemRepository, + UserRoleHelper userRoleHelper, + IUnitOfWork uow) + { + _gymRoleRepository = gymRoleRepository; + _navItemRepository = navItemRepository; + _userRoleHelper = userRoleHelper; + _uow = uow; + } + + public async Task> GetRolesAsync(HttpContext httpContext) + { + var clientId = await GetOwnerClientId(httpContext); + var roles = await _gymRoleRepository.GetRolesByClientId(clientId); + return roles.Select(ToDto).ToList(); + } + + public async Task CreateRoleAsync(CreateGymRoleRequest request, HttpContext httpContext) + { + var clientId = await GetOwnerClientId(httpContext); + var role = new GymRole { FKClientId = clientId, Name = request.Name }; + var created = await _gymRoleRepository.CreateRole(role); + await _uow.SaveChangesAsync(); + return ToDto(created); + } + + public async Task UpdateRoleAsync(Guid gymRoleId, UpdateGymRoleRequest request, HttpContext httpContext) + { + var clientId = await GetOwnerClientId(httpContext); + var role = await GetOwnedRole(gymRoleId, clientId); + + if (request.Name != null) role.Name = request.Name; + if (request.IsActive.HasValue) role.IsActive = request.IsActive.Value; + + var updated = await _gymRoleRepository.UpdateRole(role); + await _uow.SaveChangesAsync(); + return ToDto(updated); + } + + public async Task DeleteRoleAsync(Guid gymRoleId, HttpContext httpContext) + { + var clientId = await GetOwnerClientId(httpContext); + var role = await GetOwnedRole(gymRoleId, clientId); + await _gymRoleRepository.DeleteRole(role); + await _uow.SaveChangesAsync(); + } + + public async Task SetRolePermissionsAsync(Guid gymRoleId, SetRolePermissionsRequest request, HttpContext httpContext) + { + var clientId = await GetOwnerClientId(httpContext); + var role = await GetOwnedRole(gymRoleId, clientId); + var requestedKeys = request.NavKeys?.Distinct().ToList() ?? new List(); + var assignableKeys = (await _navItemRepository.GetActiveNavItemsOrdered()) + .Where(item => !item.IsOwnerOnly && item.Key != "dashboard") + .Select(item => item.Key) + .ToHashSet(); + var invalidKeys = requestedKeys.Where(key => !assignableKeys.Contains(key)).ToList(); + if (invalidKeys.Count > 0) + throw new BadRequestException($"Invalid role permission: {string.Join(", ", invalidKeys)}."); + + await _gymRoleRepository.ReplacePermissions(gymRoleId, requestedKeys); + await _uow.SaveChangesAsync(); + + var refreshed = await _gymRoleRepository.GetRoleById(gymRoleId) + ?? throw new NotFoundException("Role not found."); + return ToDto(refreshed); + } + + public async Task AssignStaffRoleAsync(Guid staffId, AssignStaffRoleRequest request, HttpContext httpContext) + { + var clientId = await GetOwnerClientId(httpContext); + var assigned = await _gymRoleRepository.AssignStaffRole(staffId, clientId, request.GymRoleId); + if (!assigned) + throw new NotFoundException("Staff member or role not found for this gym."); + await _uow.SaveChangesAsync(); + } + + // Every public method funnels through here: only a caller who resolves to a Client + // (the gym owner) can manage roles. Staff — regardless of any nav-section permission + // they've been granted, including "staff" — are rejected before any GymRole/ + // GymRolePermission data is ever touched. + private async Task GetOwnerClientId(HttpContext httpContext) + { + var userId = DataExtractor.GetUserIdFromContext(httpContext); + return await _userRoleHelper.GetOwnedClientId(userId) + ?? throw new UnauthorizedAccessException("Only the gym owner can manage roles and permissions."); + } + + private async Task GetOwnedRole(Guid gymRoleId, Guid clientId) + { + var role = await _gymRoleRepository.GetRoleById(gymRoleId) + ?? throw new NotFoundException("Role not found."); + if (role.FKClientId != clientId) + throw new NotFoundException("Role not found."); + return role; + } + + private static GymRoleDto ToDto(GymRole role) => new() + { + GymRoleId = role.GymRoleId, + Name = role.Name, + IsActive = role.IsActive, + NavKeys = role.Permissions?.Select(p => p.NavItemKey).ToList() ?? new List() + }; + } +} diff --git a/Services/JobRunHistory.cs b/Services/JobRunHistory.cs new file mode 100644 index 0000000..ffe40c2 --- /dev/null +++ b/Services/JobRunHistory.cs @@ -0,0 +1,49 @@ +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Services +{ + public sealed record JobRunRecord( + string JobName, Guid ClientId, DateTime RunAtUtc, string Status, + int SubscriptionsRenewed, string? ErrorMessage); + + // Persisted in CentralDbContext.JobRunLogs. Callers pass in whichever CentralDbContext they + // already have open -- background jobs get theirs from TenantSchemaIterator (central is one + // fixed database regardless of tenant, so any instance points at the same schema), the admin + // controller gets a normal DI-injected one since it runs inside a real HTTP request. + public interface IJobRunHistory + { + Task RecordAsync(CentralDbContext central, JobRunRecord record, CancellationToken ct = default); + Task> GetRecentAsync(CentralDbContext central, string jobName, int take, CancellationToken ct = default); + } + + public sealed class DbJobRunHistory : IJobRunHistory + { + public async Task RecordAsync(CentralDbContext central, JobRunRecord record, CancellationToken ct = default) + { + central.JobRunLogs.Add(new JobRunLog + { + JobRunLogId = Guid.NewGuid(), + JobName = record.JobName, + FKClientId = record.ClientId, + RunAtUtc = record.RunAtUtc, + Status = record.Status, + SubscriptionsRenewed = record.SubscriptionsRenewed, + ErrorMessage = record.ErrorMessage + }); + await central.SaveChangesAsync(ct); + } + + public async Task> GetRecentAsync( + CentralDbContext central, string jobName, int take, CancellationToken ct = default) + { + return await central.JobRunLogs + .AsNoTracking() + .Where(r => r.JobName == jobName) + .OrderByDescending(r => r.RunAtUtc) + .Take(take) + .Select(r => new JobRunRecord(r.JobName, r.FKClientId, r.RunAtUtc, r.Status, r.SubscriptionsRenewed, r.ErrorMessage)) + .ToListAsync(ct); + } + } +} diff --git a/Services/MealService.cs b/Services/MealService.cs new file mode 100644 index 0000000..45c03f6 --- /dev/null +++ b/Services/MealService.cs @@ -0,0 +1,975 @@ +using Fitness_core_BE.Models; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Exceptions; + +namespace Fitness_core_BE.Services.Meal +{ + public class MealService : IMealService + { + private readonly IMealRepository _repository; + private readonly UserRoleHelper _userRoleHelper; + private readonly IUnitOfWork _uow; + private readonly IActivityLogService _activityLogService; + private readonly IPushNotificationService _pushNotificationService; + + public MealService(IMealRepository repository, UserRoleHelper userRoleHelper, IUnitOfWork uow, IActivityLogService activityLogService, IPushNotificationService pushNotificationService) + { + _repository = repository; + _userRoleHelper = userRoleHelper; + _uow = uow; + _activityLogService = activityLogService; + _pushNotificationService = pushNotificationService; + } + + // Meal Category operations + public async Task GetMealCategoryList(object payload, HttpContext httpContext) + { + try + { + var categories = await _repository.GetMealCategoryListAsync(); + return categories; + } + catch + { + throw; + } + } + + public async Task CreateMealCategory(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + var mealCategory = new MealCategory + { + Name = data["name"].GetString()!, + Description = data.ContainsKey("description") ? data["description"].GetString() : null, + CreateDate = DateTime.UtcNow + }; + + await _repository.CreateMealCategoryAsync(mealCategory); + await _uow.CommitAsync(); + + try + { + await _activityLogService.LogActivity(new { description = $"Meal category created with ID {mealCategory.MealCategoryId}" }, httpContext); + } + catch (Exception logEx) + { + Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}"); + } + + return mealCategory; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + // Meal Plan operations + public async Task GetMealPlanList(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + string userType = await _userRoleHelper.GetUserType(userId); + Guid clientId; + if (userType == "Customer") + { + Guid cusId = await _userRoleHelper.GetCusId(userId); + try + { + clientId = await _userRoleHelper.GetClientIdByCusId(cusId); + } + catch (NotFoundException) + { + // Not every customer has joined a gym yet — they should still see the + // platform-wide admin suggestions (FKClientId == null in the repo query). + clientId = Guid.Empty; + } + } + else + { + clientId = await _userRoleHelper.GetClientId(userId); + } + try + { + var mealPlans = await _repository.GetMealPlanListAsync(userType, clientId); + return mealPlans; + } + catch + { + throw; + } + } + + public async Task GetMealPlanById(object payload, HttpContext httpContext) + { + try + { + var data = DeserializePayload.Deserialize(payload); + Guid mealPlanId = Guid.Parse(data["mealPlanId"].GetString()!); + + var mealPlan = await _repository.GetMealPlanByIdAsync(mealPlanId); + + if (mealPlan == null) + throw new KeyNotFoundException($"Meal plan with ID {mealPlanId} not found"); + + // Parse MealItems JSON strings to objects in nested meal items + if (mealPlan.MealItems != null) + { + foreach (var item in mealPlan.MealItems) + { + var itemAsString = item.MealItems as string; + if (!string.IsNullOrEmpty(itemAsString)) + { + try + { + item.MealItems = System.Text.Json.JsonSerializer.Deserialize(itemAsString); + } + catch + { + // Keep as string if parsing fails + } + } + } + } + + return mealPlan; + } + catch + { + throw new Exception("An error occurred while fetching the meal plan details."); + } + } + + public async Task GetMealPlanByCustomerId(object payload, HttpContext httpContext) + { + try + { + Guid customerId; + + var data = DeserializePayload.Deserialize(payload); + + if (data.ContainsKey("customerId") && + !string.IsNullOrEmpty(data["customerId"].GetString())) + { + customerId = Guid.Parse(data["customerId"].GetString()!); + } + else + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + customerId = await _userRoleHelper.GetCusId(userId); + } + + var mealPlans = await _repository.GetMealPlansByCustomerIdAsync(customerId); + + + foreach (var mealPlan in mealPlans) + { + if (mealPlan.MealItems != null) + { + foreach (var item in mealPlan.MealItems) + { + var itemAsString = item.MealItems as string; + if (!string.IsNullOrEmpty(itemAsString)) + { + try + { + item.MealItems = System.Text.Json.JsonSerializer.Deserialize(itemAsString); + } + catch + { + // Keep as string if parsing fails + } + } + } + } + } + + return mealPlans; + } + catch + { + throw new Exception("An error occurred while fetching meal plans for the customer."); + } + } + + public async Task CreateMealPlan(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + + Guid clientId = await _userRoleHelper.GetClientId(userId); + + var newMealCategoryId = Guid.Parse(data["mealCategoryId"].GetString()!); + if (!await _repository.MealCategoryExistsAsync(newMealCategoryId)) + throw new NotFoundException("Meal category not found."); + + // Create meal plan + var mealPlan = new MealPlan + { + Name = data["name"].GetString()!, + Description = data.ContainsKey("description") ? data["description"].GetString() : null, + IsTemplate = data.ContainsKey("isTemplate") && data["isTemplate"].GetBoolean(), + FKMealCategoryId = newMealCategoryId, + FKClientId = clientId, + CreateDate = DateTime.UtcNow + }; + + await _repository.CreateMealPlanAsync(mealPlan); + + // Handle customer assignments if provided + if (data.ContainsKey("customerIds") && data["customerIds"].ValueKind == System.Text.Json.JsonValueKind.Array) + { + var customerIdsArray = data["customerIds"].EnumerateArray(); + foreach (var customerIdElement in customerIdsArray) + { + if (Guid.TryParse(customerIdElement.GetString(), out var customerId)) + { + var customerMeal = new CustomerMeal + { + FKCustomerId = customerId, + FKMealPlanId = mealPlan.MealPlanId, + AssignedDate = DateTime.UtcNow, + IsActive = true + }; + await _repository.AssignMealToCustomerAsync(customerMeal); + } + } + } + // Backward compatibility: single customerId field + else if (data.ContainsKey("customerId") && !string.IsNullOrEmpty(data["customerId"].GetString())) + { + var customerId = Guid.Parse(data["customerId"].GetString()!); + var customerMeal = new CustomerMeal + { + FKCustomerId = customerId, + FKMealPlanId = mealPlan.MealPlanId, + AssignedDate = DateTime.UtcNow, + IsActive = true + }; + await _repository.AssignMealToCustomerAsync(customerMeal); + } + + // Handle meal items if provided + if (data.ContainsKey("mealItems") && data["mealItems"].ValueKind == System.Text.Json.JsonValueKind.Array) + { + var mealItemsArray = data["mealItems"].EnumerateArray(); + foreach (var mealItemData in mealItemsArray) + { + // Only create meal if name is provided + if (mealItemData.TryGetProperty("name", out var nameElement) && + !string.IsNullOrEmpty(nameElement.GetString())) + { + // Parse nested mealItems JSON + string? mealItemsJson = null; + if (mealItemData.TryGetProperty("mealItems", out var mealItemsElement)) + { + if (mealItemsElement.ValueKind == System.Text.Json.JsonValueKind.Array) + { + mealItemsJson = System.Text.Json.JsonSerializer.Serialize(mealItemsElement); + } + else if (mealItemsElement.ValueKind == System.Text.Json.JsonValueKind.String) + { + mealItemsJson = mealItemsElement.GetString(); + } + } + + var meal = new Models.Meal + { + Name = nameElement.GetString()!, + Description = mealItemData.TryGetProperty("description", out var desc) ? desc.GetString() : null, + Calories = TryParseDecimal(mealItemData, "calories"), + Protein = TryParseDecimal(mealItemData, "protein"), + Carbohydrates = TryParseDecimal(mealItemData, "carbohydrates"), + Fats = TryParseDecimal(mealItemData, "fats"), + ServingSize = mealItemData.TryGetProperty("servingSize", out var serving) ? serving.GetString() : null, + MealItems = mealItemsJson, + PreparationNotes = mealItemData.TryGetProperty("preparationNotes", out var notes) ? notes.GetString() : null, + FKMealPlanId = mealPlan.MealPlanId, + CreateDate = DateTime.UtcNow + }; + + await _repository.CreateMealAsync(meal); + } + } + } + + await _uow.CommitAsync(); + + try + { + await _activityLogService.LogActivity( + new { description = $"Meal plan created with ID {mealPlan.MealPlanId}" }, + httpContext + ); + } + catch (Exception logEx) + { + Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}"); + } + + // Fetch and return the created meal plan with parsed JSON + var createdMealPlan = await _repository.GetMealPlanByIdAsync(mealPlan.MealPlanId); + + if (createdMealPlan == null) + throw new KeyNotFoundException($"Meal plan with ID {mealPlan.MealPlanId} not found after creation"); + + // Parse MealItems JSON strings to objects in nested meal items + if (createdMealPlan.MealItems != null) + { + foreach (var item in createdMealPlan.MealItems) + { + var itemAsString = item.MealItems as string; + if (!string.IsNullOrEmpty(itemAsString)) + { + try + { + item.MealItems = System.Text.Json.JsonSerializer.Deserialize(itemAsString); + } + catch + { + // Keep as string if parsing fails + } + } + } + } + + return createdMealPlan; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + // Plans created via the AI chat flow aren't given a category by the customer — the + // mobile app used to guess one by fetching the category list and taking the first + // entry, which was both an extra round-trip and an arbitrary pick. Categories are a + // global taxonomy (MealCategory has no FKClientId), so a single well-known "AI + // Generated" category is get-or-created here and reused for every gym's AI plans. + private const string AiGeneratedCategoryName = "AI Generated"; + + private async Task GetOrCreateAiMealCategoryIdAsync() + { + var categories = await _repository.GetMealCategoryListAsync(); + var existing = categories.FirstOrDefault(c => + string.Equals(c.Name, AiGeneratedCategoryName, StringComparison.OrdinalIgnoreCase)); + if (existing != null) + return existing.MealCategoryId; + + var created = await _repository.CreateMealCategoryAsync(new MealCategory + { + MealCategoryId = Guid.NewGuid(), + Name = AiGeneratedCategoryName, + Description = "Plans generated by the AI meal planner", + IsActive = true, + CreateDate = DateTime.UtcNow + }); + return created.MealCategoryId; + } + + public async Task CreateCustomerMealPlan(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + Guid customerId; + if (data.ContainsKey("customerId") && + !string.IsNullOrEmpty(data["customerId"].GetString())) + { + customerId = Guid.Parse(data["customerId"].GetString()!); + } + else + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + customerId = await _userRoleHelper.GetCusId(userId); + } + + // The plan belongs to the customer's own gym, not a fixed placeholder client. + Guid clientId = await _userRoleHelper.GetClientIdByCusId(customerId); + + Guid mealCategoryId = data.ContainsKey("mealCategoryId") && !string.IsNullOrEmpty(data["mealCategoryId"].GetString()) + ? Guid.Parse(data["mealCategoryId"].GetString()!) + : await GetOrCreateAiMealCategoryIdAsync(); + + var mealPlan = new MealPlan + { + Name = data["name"].GetString()!, + Description = data.ContainsKey("description") ? data["description"].GetString() : null, + IsTemplate = data.ContainsKey("isTemplate") && data["isTemplate"].GetBoolean(), + FKMealCategoryId = mealCategoryId, + FKClientId = clientId, + CreateDate = DateTime.UtcNow + }; + + await _repository.CreateMealPlanAsync(mealPlan); + + if (data.ContainsKey("mealItems") && data["mealItems"].ValueKind == System.Text.Json.JsonValueKind.Array) + { + var mealItemsArray = data["mealItems"].EnumerateArray(); + foreach (var mealItemData in mealItemsArray) + { + if (mealItemData.TryGetProperty("name", out var nameElement) && + !string.IsNullOrEmpty(nameElement.GetString())) + { + string? mealItemsJson = null; + if (mealItemData.TryGetProperty("mealItems", out var mealItemsElement)) + { + if (mealItemsElement.ValueKind == System.Text.Json.JsonValueKind.Array) + { + mealItemsJson = System.Text.Json.JsonSerializer.Serialize(mealItemsElement); + } + else if (mealItemsElement.ValueKind == System.Text.Json.JsonValueKind.String) + { + mealItemsJson = mealItemsElement.GetString(); + } + } + + var meal = new Models.Meal + { + Name = nameElement.GetString()!, + Description = mealItemData.TryGetProperty("description", out var desc) ? desc.GetString() : null, + Calories = TryParseDecimal(mealItemData, "calories"), + Protein = TryParseDecimal(mealItemData, "protein"), + Carbohydrates = TryParseDecimal(mealItemData, "carbohydrates"), + Fats = TryParseDecimal(mealItemData, "fats"), + ServingSize = mealItemData.TryGetProperty("servingSize", out var serving) ? serving.GetString() : null, + MealItems = mealItemsJson, + PreparationNotes = mealItemData.TryGetProperty("preparationNotes", out var notes) ? notes.GetString() : null, + FKMealPlanId = mealPlan.MealPlanId, + CreateDate = DateTime.UtcNow + }; + + await _repository.CreateMealAsync(meal); + } + } + } + + var customerMeal = new CustomerMeal + { + FKCustomerId = customerId, + FKMealPlanId = mealPlan.MealPlanId, + AssignedDate = DateTime.UtcNow, + IsActive = true, + IsFollowing = true + }; + await _repository.AssignMealToCustomerAsync(customerMeal); + + await _uow.CommitAsync(); + + try + { + await _activityLogService.LogActivity( + new { description = $"Customer meal plan created with ID {mealPlan.MealPlanId}" }, + httpContext + ); + } + catch (Exception logEx) + { + Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}"); + } + + var createdMealPlan = await _repository.GetMealPlanByIdAsync(mealPlan.MealPlanId); + + if (createdMealPlan == null) + throw new KeyNotFoundException($"Meal plan with ID {mealPlan.MealPlanId} not found after creation"); + + if (createdMealPlan.MealItems != null) + { + foreach (var item in createdMealPlan.MealItems) + { + var itemAsString = item.MealItems as string; + if (!string.IsNullOrEmpty(itemAsString)) + { + try + { + item.MealItems = System.Text.Json.JsonSerializer.Deserialize(itemAsString); + } + catch + { + // Keep as string if parsing fails + } + } + } + } + + return createdMealPlan; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + // Meal Item operations + public async Task GetMealItemsByMealPlanId(object payload, HttpContext httpContext) + { + try + { + var data = DeserializePayload.Deserialize(payload); + Guid mealPlanId = Guid.Parse(data["mealPlanId"].GetString()!); + + var mealItems = await _repository.GetMealsByMealPlanIdAsync(mealPlanId); + + // Parse MealItems JSON strings to objects + foreach (var item in mealItems) + { + var itemAsString = item.MealItems as string; + if (!string.IsNullOrEmpty(itemAsString)) + { + try + { + item.MealItems = System.Text.Json.JsonSerializer.Deserialize(itemAsString); + } + catch + { + // Keep as string if parsing fails + } + } + } + + return mealItems; + } + catch + { + throw; + } + } + + public async Task CreateMealItem(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + // Parse mealItems array and serialize to JSON string + string? mealItemsJson = null; + if (data.ContainsKey("mealItems")) + { + var mealItemsElement = data["mealItems"]; + if (mealItemsElement.ValueKind == System.Text.Json.JsonValueKind.Array) + { + mealItemsJson = System.Text.Json.JsonSerializer.Serialize(mealItemsElement); + } + else if (mealItemsElement.ValueKind == System.Text.Json.JsonValueKind.String) + { + mealItemsJson = mealItemsElement.GetString(); + } + } + + var mealItem = new Models.Meal + { + Name = data["name"].GetString()!, + Description = data.ContainsKey("description") ? data["description"].GetString() : null, + Calories = data.ContainsKey("calories") && !string.IsNullOrEmpty(data["calories"].GetString()) + ? decimal.Parse(data["calories"].GetString()!) + : (decimal?)null, + Protein = data.ContainsKey("protein") && !string.IsNullOrEmpty(data["protein"].GetString()) + ? decimal.Parse(data["protein"].GetString()!) + : (decimal?)null, + Carbohydrates = data.ContainsKey("carbohydrates") && !string.IsNullOrEmpty(data["carbohydrates"].GetString()) + ? decimal.Parse(data["carbohydrates"].GetString()!) + : (decimal?)null, + Fats = data.ContainsKey("fats") && !string.IsNullOrEmpty(data["fats"].GetString()) + ? decimal.Parse(data["fats"].GetString()!) + : (decimal?)null, + ServingSize = data.ContainsKey("servingSize") ? data["servingSize"].GetString() : null, + MealItems = mealItemsJson, + PreparationNotes = data.ContainsKey("preparationNotes") ? data["preparationNotes"].GetString() : null, + FKMealPlanId = Guid.Parse(data["mealPlanId"].GetString()!), + CreateDate = DateTime.UtcNow + }; + + await _repository.CreateMealAsync(mealItem); + await _uow.CommitAsync(); + + // Log activity - non-blocking, failures won't crash the operation + try + { + await _activityLogService.LogActivity(new { description = $"Meal item created with ID {mealItem.MealId}" }, httpContext); + } + catch (Exception logEx) + { + Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}"); + } + + // Parse mealItems JSON string to object before returning + object? parsedMealItems = mealItem.MealItems; + if (!string.IsNullOrEmpty(mealItem.MealItems)) + { + try + { + parsedMealItems = System.Text.Json.JsonSerializer.Deserialize(mealItem.MealItems); + } + catch + { + // Keep as string if parsing fails + } + } + + return new + { + mealItem.MealId, + mealItem.Name, + mealItem.Description, + mealItem.Calories, + mealItem.Protein, + mealItem.Carbohydrates, + mealItem.Fats, + mealItem.ServingSize, + MealItems = parsedMealItems, + mealItem.PreparationNotes, + FKMealPlanId = mealItem.FKMealPlanId, + mealItem.IsActive, + mealItem.CreateDate + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + // Update Meal Plan + public async Task UpdateMealPlan(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + Guid mealPlanId = Guid.Parse(data["mealPlanId"].GetString()!); + + var existingMealPlan = await _repository.GetMealPlanEntityByIdAsync(mealPlanId); + if (existingMealPlan == null) + throw new KeyNotFoundException($"Meal plan with ID {mealPlanId} not found"); + + // Update meal plan properties + if (data.ContainsKey("name") && !string.IsNullOrEmpty(data["name"].GetString())) + existingMealPlan.Name = data["name"].GetString()!; + + if (data.ContainsKey("description")) + existingMealPlan.Description = data["description"].GetString(); + + if (data.ContainsKey("isTemplate")) + existingMealPlan.IsTemplate = data["isTemplate"].GetBoolean(); + + if (data.ContainsKey("mealCategoryId")) + existingMealPlan.FKMealCategoryId = Guid.Parse(data["mealCategoryId"].GetString()!); + + await _repository.UpdateMealPlanAsync(existingMealPlan); + + // Handle meal items update if provided + if (data.ContainsKey("mealItems") && data["mealItems"].ValueKind == System.Text.Json.JsonValueKind.Array) + { + // Get existing meal items for this plan + var existingMeals = await _repository.GetMealsByMealPlanIdAsync(mealPlanId); + + // Delete all existing meals (clean slate approach) + foreach (var existingMeal in existingMeals) + { + await _repository.DeleteMealAsync(existingMeal.MealId); + } + + // Create new meals from payload + var mealItemsArray = data["mealItems"].EnumerateArray(); + foreach (var mealItemData in mealItemsArray) + { + // Only create meal if name is provided + if (mealItemData.TryGetProperty("name", out var nameElement) && + !string.IsNullOrEmpty(nameElement.GetString())) + { + string? mealItemsJson = null; + if (mealItemData.TryGetProperty("mealItems", out var mealItemsElement)) + { + if (mealItemsElement.ValueKind == System.Text.Json.JsonValueKind.Array) + { + mealItemsJson = System.Text.Json.JsonSerializer.Serialize(mealItemsElement); + } + else if (mealItemsElement.ValueKind == System.Text.Json.JsonValueKind.String) + { + mealItemsJson = mealItemsElement.GetString(); + } + } + + var meal = new Models.Meal + { + Name = nameElement.GetString()!, + Description = mealItemData.TryGetProperty("description", out var desc) ? desc.GetString() : null, + Calories = TryParseDecimal(mealItemData, "calories"), + Protein = TryParseDecimal(mealItemData, "protein"), + Carbohydrates = TryParseDecimal(mealItemData, "carbohydrates"), + Fats = TryParseDecimal(mealItemData, "fats"), + ServingSize = mealItemData.TryGetProperty("servingSize", out var serving) ? serving.GetString() : null, + MealItems = mealItemsJson, + PreparationNotes = mealItemData.TryGetProperty("preparationNotes", out var notes) ? notes.GetString() : null, + FKMealPlanId = mealPlanId, + CreateDate = DateTime.UtcNow + }; + + await _repository.CreateMealAsync(meal); + } + } + } + + await _uow.CommitAsync(); + + try + { + await _activityLogService.LogActivity( + new { description = $"Meal plan updated with ID {mealPlanId}" }, + httpContext + ); + } + catch (Exception logEx) + { + Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}"); + } + + var updatedMealPlan = await _repository.GetMealPlanByIdAsync(mealPlanId); + + if (updatedMealPlan == null) + throw new KeyNotFoundException($"Meal plan with ID {mealPlanId} not found after update"); + + // Parse MealItems JSON strings to objects in nested meal items + if (updatedMealPlan.MealItems != null) + { + foreach (var item in updatedMealPlan.MealItems) + { + var itemAsString = item.MealItems as string; + if (!string.IsNullOrEmpty(itemAsString)) + { + try + { + item.MealItems = System.Text.Json.JsonSerializer.Deserialize(itemAsString); + } + catch + { + // Keep as string if parsing fails + } + } + } + } + + return updatedMealPlan; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + // Delete Meal Plan + public async Task DeleteMealPlan(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + Guid mealPlanId = Guid.Parse(data["mealPlanId"].GetString()!); + + var existingMealPlan = await _repository.GetMealPlanEntityByIdAsync(mealPlanId); + if (existingMealPlan == null) + throw new KeyNotFoundException($"Meal plan with ID {mealPlanId} not found"); + + // Delete all associated meal items first (foreign key constraint) + var existingMeals = await _repository.GetMealsByMealPlanIdAsync(mealPlanId); + foreach (var meal in existingMeals) + { + await _repository.DeleteMealAsync(meal.MealId); + } + + // Delete the meal plan + await _repository.DeleteMealPlanAsync(mealPlanId); + await _uow.CommitAsync(); + + try + { + await _activityLogService.LogActivity( + new { description = $"Meal plan deleted with ID {mealPlanId}" }, + httpContext + ); + } + catch (Exception logEx) + { + Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}"); + } + + return new + { + Success = true, + Message = $"Meal plan with ID {mealPlanId} has been deleted successfully" + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + // Helper method + private decimal? TryParseDecimal(System.Text.Json.JsonElement element, string propertyName) + { + if (element.TryGetProperty(propertyName, out var prop)) + { + if (prop.ValueKind == System.Text.Json.JsonValueKind.Number) + { + return prop.GetDecimal(); + } + else if (prop.ValueKind == System.Text.Json.JsonValueKind.String) + { + var strValue = prop.GetString(); + if (!string.IsNullOrEmpty(strValue) && decimal.TryParse(strValue, out var result)) + { + return result; + } + } + } + return null; + } + + // Helper for JsonElement + private decimal? TryParseDecimal(Dictionary data, string key) + { + if (!data.ContainsKey(key)) + return null; + + var element = data[key]; + if (element.ValueKind == System.Text.Json.JsonValueKind.Number) + return element.GetDecimal(); + + if (element.ValueKind == System.Text.Json.JsonValueKind.String) + { + var strValue = element.GetString(); + if (!string.IsNullOrEmpty(strValue) && decimal.TryParse(strValue, out var result)) + return result; + } + + return null; + } + + // Customer Meal Assignment operations + public async Task AssignMealToCustomer(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + Guid mealPlanId = Guid.Parse(data["mealPlanId"].GetString()!); + Guid customerId = Guid.Parse(data["customerId"].GetString()!); + + var customerMeal = new CustomerMeal + { + FKCustomerId = customerId, + FKMealPlanId = mealPlanId, + AssignedDate = DateTime.UtcNow, + Notes = data.ContainsKey("notes") ? data["notes"].GetString() : null, + IsActive = true + }; + + await _repository.AssignMealToCustomerAsync(customerMeal); + await _uow.CommitAsync(); + + try + { + await _activityLogService.LogActivity( + new { description = $"Meal plan {mealPlanId} assigned to customer {customerId}" }, + httpContext + ); + } + catch (Exception logEx) + { + Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}"); + } + + try + { + var plan = await _repository.GetMealPlanEntityByIdAsync(mealPlanId); + var planName = plan?.Name ?? "a meal plan"; + await _pushNotificationService.NotifyCustomerAsync( + customerId, + "New meal plan assigned", + $"Your coach assigned you \"{planName}\". Tap to view.", + "MealPlanAssigned", + "MealPlanSuggestionDetail", + new { mealPlanId } + ); + } + catch (Exception notifyEx) + { + Console.WriteLine($"Push notification failed (non-critical): {notifyEx.Message}"); + } + + return new + { + customerMeal.CustomerMealId, + customerMeal.FKCustomerId, + customerMeal.FKMealPlanId, + customerMeal.AssignedDate, + customerMeal.Notes, + customerMeal.IsActive + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task UnassignMealFromCustomer(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + Guid customerMealId = Guid.Parse(data["customerMealId"].GetString()!); + + var result = await _repository.UnassignMealFromCustomerAsync(customerMealId); + + if (!result) + { + throw new KeyNotFoundException($"Customer meal assignment with ID {customerMealId} not found"); + } + + await _uow.CommitAsync(); + + try + { + await _activityLogService.LogActivity( + new { description = $"Meal assignment {customerMealId} removed" }, + httpContext + ); + } + catch (Exception logEx) + { + Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}"); + } + + return new { success = true, message = "Meal unassigned from customer successfully" }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + } +} diff --git a/Services/NavigationService.cs b/Services/NavigationService.cs new file mode 100644 index 0000000..ac01ce5 --- /dev/null +++ b/Services/NavigationService.cs @@ -0,0 +1,49 @@ +using Fitness_core_BE.DTOs.Rbac; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; + +namespace Fitness_core_BE.Services +{ + public class NavigationService : INavigationService + { + private readonly IPermissionService _permissionService; + private readonly INavItemRepository _navItemRepository; + + public NavigationService( + IPermissionService permissionService, + INavItemRepository navItemRepository) + { + _permissionService = permissionService; + _navItemRepository = navItemRepository; + } + + public async Task GetNavigationAsync(HttpContext httpContext) + { + var permissionContext = + await _permissionService.GetPermissionContextAsync(httpContext); + var allItems = await _navItemRepository.GetActiveNavItemsOrdered(); + + var visibleItems = allItems + .Where(item => + item.Key == "dashboard" || + permissionContext.IsOwner || + permissionContext.AllowedNavKeys.Contains(item.Key)) + .Where(item => !item.IsOwnerOnly || permissionContext.IsOwner) + .Select(item => new NavItemDto + { + Key = item.Key, + Label = item.Label, + ShortLabel = item.ShortLabel, + Href = item.Href, + Icon = item.Icon + }) + .ToList(); + + return new NavResponseDto + { + IsOwner = permissionContext.IsOwner, + Items = visibleItems + }; + } + } +} diff --git a/Services/OverdueLockJob.cs b/Services/OverdueLockJob.cs new file mode 100644 index 0000000..caabc1b --- /dev/null +++ b/Services/OverdueLockJob.cs @@ -0,0 +1,86 @@ +using Fitness_core_BE.Infra.Tenancy; +using Fitness_core_BE.Interfaces; + +namespace Fitness_core_BE.Services +{ + /// + /// Periodically locks (and un-locks) member fingerprint access based on each client's + /// configurable Client.LockAfterDaysOverdue setting (req 7). Reuses the existing + /// AccessDeviceService.LockCustomer/UnlockCustomer (BlackListMember/WhiteListMember) + /// pipeline — this job only decides *who* should be locked, not *how*. + /// + /// This is a background service with no HttpContext, so TenantConnectionResolver's normal + /// per-request tenant resolution can never apply here -- a DI-scoped TenantDbContext always + /// resolves to the same single fallback schema regardless of which client is being checked. + /// Every client's overdue-check therefore opens its OWN schema directly via + /// TenantSchemaIterator instead of using the scope's injected TenantDbContext. + /// + /// The per-client algorithm itself lives in OverdueLockRunner so + /// AdminBackgroundJobsController's manual "trigger now" endpoint can call the exact same + /// code path instead of duplicating it -- this class only owns the 6-hour loop and the + /// central-only client lookup. + /// + public class OverdueLockJob : BackgroundService + { + private static readonly TimeSpan Interval = TimeSpan.FromHours(6); + + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public OverdueLockJob(IServiceScopeFactory scopeFactory, ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await RunOnceAsync(stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "OverdueLockJob run failed."); + } + + try + { + await Task.Delay(Interval, stoppingToken); + } + catch (TaskCanceledException) + { + // Shutting down. + } + } + } + + private async Task RunOnceAsync(CancellationToken ct) + { + using var scope = _scopeFactory.CreateScope(); + var iterator = scope.ServiceProvider.GetRequiredService(); + var runner = scope.ServiceProvider.GetRequiredService(); + + var now = DateTime.UtcNow; + + // Central-only lookup -- safe to use the scope's normal DI-resolved repository, + // since GetClientsWithOverdueLockPolicyAsync only ever touches CentralDbContext. + var bootstrapRepo = scope.ServiceProvider.GetRequiredService(); + var clients = await bootstrapRepo.GetClientsWithOverdueLockPolicyAsync(ct); + + foreach (var client in clients) + { + try + { + await runner.RunForClientAsync(client, now, iterator, ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "OverdueLockJob failed for client {ClientId}.", client.ClientId); + } + } + } + } +} diff --git a/Services/OverdueLockRunner.cs b/Services/OverdueLockRunner.cs new file mode 100644 index 0000000..4f6e43f --- /dev/null +++ b/Services/OverdueLockRunner.cs @@ -0,0 +1,69 @@ +using Fitness_core_BE.Infra.Tenancy; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Models; +using Fitness_core_BE.Repos; + +namespace Fitness_core_BE.Services +{ + // The per-client overdue-lock algorithm, extracted verbatim from OverdueLockJob so the + // admin "trigger now" endpoint (AdminBackgroundJobsController) can call the exact same code + // path instead of duplicating it. Pure extraction -- no change to the locking logic itself. + // Mirrors the SubscriptionRenewalRunner/SubscriptionRenewalJob split. + public class OverdueLockRunner(ILogger logger) + { + private readonly ILogger _logger = logger; + + public async Task RunForClientAsync(Client client, DateTime now, TenantSchemaIterator iterator, CancellationToken ct) + { + await using var schemaCtx = await iterator.OpenSchemaForClientAsync(client.ClientId, ct); + if (schemaCtx == null) + { + _logger.LogWarning( + "OverdueLockJob: client {ClientId} has a lock policy but no provisioned tenant schema; skipping.", + client.ClientId); + return; + } + + var repository = new OverdueLockRepository(schemaCtx.Tenant, schemaCtx.Central); + var accessDeviceService = new AccessDeviceService( + new EFUnitOfWork(schemaCtx.Central, schemaCtx.Tenant), + new AccessDeviceRepo(schemaCtx.Tenant, schemaCtx.Central), + new AttendanceRepository(schemaCtx.Tenant), + new DeviceCommandRepo(schemaCtx.Tenant), + new PairingCodeRepo(schemaCtx.Central)); + + var effectiveDays = client.LockAfterDaysOverdue ?? Client.DefaultLockAfterDaysOverdue; + var thresholdDate = now.AddDays(-effectiveDays); + + var overdueCustomerIds = await repository.GetOverdueCustomerIdsAsync( + client.ClientId, thresholdDate, ct); + + foreach (var customerId in overdueCustomerIds) + { + var customer = await repository.GetCustomerByIdAsync(customerId, ct); + if (customer == null) + continue; + var alreadyLocked = await repository.IsCustomerAccessDisabledAsync( + client.ClientId, customer.FKUserId, ct); + if (alreadyLocked) + continue; + + await accessDeviceService.SetCustomerAccessAsync( + client.ClientId, customerId, enabled: false, reason: "Overdue"); + _logger.LogInformation("Auto-locked customer {CustomerId} for client {ClientId} (overdue).", customerId, client.ClientId); + } + + // Auto-unlock members who were locked for being overdue but are no longer + // (paid, or their overdue invoice fell out of scope) — never touches manual locks. + var lockedOverdueCustomerIds = + await repository.GetCustomersLockedForOverdueAsync(client.ClientId, ct); + + foreach (var customerId in lockedOverdueCustomerIds.Except(overdueCustomerIds)) + { + await accessDeviceService.SetCustomerAccessAsync( + client.ClientId, customerId, enabled: true, reason: null); + _logger.LogInformation("Auto-unlocked customer {CustomerId} for client {ClientId} (no longer overdue).", customerId, client.ClientId); + } + } + } +} diff --git a/Services/PaymentGatewayClient.cs b/Services/PaymentGatewayClient.cs new file mode 100644 index 0000000..29b52b7 --- /dev/null +++ b/Services/PaymentGatewayClient.cs @@ -0,0 +1,178 @@ +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Fitness_core_BE.DTOs.PaymentGateways; +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; + +namespace Fitness_core_BE.Services +{ + public class PaymentGatewayClient : IPaymentGatewayClient + { + private readonly IClientRepository _clientRepository; + private readonly IPaymentServiceClient _paymentServiceClient; + private readonly IUnitOfWork _uow; + private readonly HttpClient _httpClient; + private readonly string _baseUrl; + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + + public PaymentGatewayClient( + IClientRepository clientRepository, + IPaymentServiceClient paymentServiceClient, + IUnitOfWork uow, + IHttpClientFactory httpClientFactory, + IConfiguration configuration) + { + _clientRepository = clientRepository; + _paymentServiceClient = paymentServiceClient; + _uow = uow; + _httpClient = httpClientFactory.CreateClient(); + _baseUrl = (configuration["PaymentService:BaseUrl"] ?? "http://127.0.0.1:5120").TrimEnd('/'); + } + + public async Task OnboardCardAsync(Guid clientId, OnboardCardRequest request, CancellationToken ct = default) + { + var body = await SendAsync(clientId, HttpMethod.Post, "/api/v1/cards/onboard", request, ct); + return Deserialize(body); + } + + public async Task GetCardDetailsAsync(Guid clientId, string cardToken, CancellationToken ct = default) + { + var body = await SendAsync(clientId, HttpMethod.Get, $"/api/v1/cards/{Uri.EscapeDataString(cardToken)}", null, ct); + return Deserialize(body); + } + + public async Task> ListCardsAsync(Guid clientId, string customerId, CancellationToken ct = default) + { + var body = await SendAsync(clientId, HttpMethod.Get, "/api/v1/cards", null, ct, + query: $"customerId={Uri.EscapeDataString(customerId)}"); + return Deserialize>(body); + } + + public async Task DeactivateCardAsync(Guid clientId, string cardToken, CancellationToken ct = default) + { + await SendAsync(clientId, HttpMethod.Delete, $"/api/v1/cards/{Uri.EscapeDataString(cardToken)}", null, ct); + } + + public async Task SetDefaultCardAsync(Guid clientId, string cardToken, string customerId, CancellationToken ct = default) + { + var body = await SendAsync(clientId, HttpMethod.Patch, $"/api/v1/cards/{Uri.EscapeDataString(cardToken)}/default", + new { customerId }, ct); + return Deserialize(body); + } + + public async Task GetOnboardingStatusAsync(Guid clientId, string gatewayName, string sessionId, CancellationToken ct = default) + { + var body = await SendAsync(clientId, HttpMethod.Get, + $"/api/v1/cards/onboarding/{Uri.EscapeDataString(gatewayName)}/{Uri.EscapeDataString(sessionId)}", null, ct); + return Deserialize(body); + } + + public async Task AttemptPaymentAsync(Guid clientId, AttemptPaymentRequest request, CancellationToken ct = default) + { + var body = await SendAsync(clientId, HttpMethod.Post, "/api/v1/payments/attempt", request, ct); + return Deserialize(body); + } + + public async Task RefundPaymentAsync(Guid clientId, Guid paymentId, bool isVoid, CancellationToken ct = default) + { + var body = await SendAsync(clientId, HttpMethod.Post, $"/api/v1/refunds/{paymentId}", new { isVoid }, ct); + return Deserialize(body); + } + + public async Task ListPaymentsAsync(Guid clientId, string? cardToken, int page, int pageSize, CancellationToken ct = default) + { + var queryParts = new List { $"page={page}", $"pageSize={pageSize}" }; + if (!string.IsNullOrWhiteSpace(cardToken)) + { + queryParts.Add($"cardToken={Uri.EscapeDataString(cardToken)}"); + } + + var body = await SendAsync(clientId, HttpMethod.Get, "/api/v1/payments", null, ct, query: string.Join("&", queryParts)); + return Deserialize(body); + } + + private static T Deserialize(string body) + { + return JsonSerializer.Deserialize(body, JsonOptions) + ?? throw new ServiceUnavailableException("Payment-Service returned an empty response."); + } + + /// + /// Mints this client's Payment-Service Platform the first time it's needed, storing the + /// ApiKey/HmacSecret on Client since Payment-Service only ever returns them once. + /// + private async Task<(string ApiKey, string HmacSecret)> EnsureCredentialsAsync(Guid clientId, CancellationToken ct) + { + var client = await _clientRepository.GetClientEntityByIdAsync(clientId, ct) + ?? throw new NotFoundException($"Client {clientId} not found."); + + if (string.IsNullOrEmpty(client.ClientPaymentApiKey) || string.IsNullOrEmpty(client.ClientPaymentHmacSecret)) + { + var (platformId, apiKey, hmacSecret) = await _paymentServiceClient.ProvisionPlatformAsync( + clientId, client.ClientName ?? "Gym", ct); + + client.FKPaymentPlatformId = platformId; + client.ClientPaymentApiKey = apiKey; + client.ClientPaymentHmacSecret = hmacSecret; + await _uow.SaveChangesAsync(ct); + } + + return (client.ClientPaymentApiKey!, client.ClientPaymentHmacSecret!); + } + + private async Task SendAsync(Guid clientId, HttpMethod method, string path, object? body, CancellationToken ct, string? query = null) + { + var (apiKey, hmacSecret) = await EnsureCredentialsAsync(clientId, ct); + + var bodyJson = body is null ? string.Empty : JsonSerializer.Serialize(body); + var timestamp = DateTimeOffset.UtcNow.ToString("o"); + // Signed over the path only — Payment-Service's HmacValidationMiddleware signs + // against Request.Path.Value, which never includes the query string. + var signature = ComputeSignature(hmacSecret, method.Method, path, timestamp, bodyJson); + + var url = string.IsNullOrEmpty(query) ? $"{_baseUrl}{path}" : $"{_baseUrl}{path}?{query}"; + using var httpRequest = new HttpRequestMessage(method, url); + httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + httpRequest.Headers.Add("X-Api-Key", apiKey); + httpRequest.Headers.Add("X-Timestamp", timestamp); + httpRequest.Headers.Add("X-Signature", signature); + + if (body is not null) + { + httpRequest.Content = new StringContent(bodyJson, Encoding.UTF8, "application/json"); + } + + HttpResponseMessage response; + try + { + response = await _httpClient.SendAsync(httpRequest, ct); + } + catch (HttpRequestException ex) + { + throw new ServiceUnavailableException("Could not reach Payment-Service.", ex); + } + + var responseBody = await response.Content.ReadAsStringAsync(ct); + if (!response.IsSuccessStatusCode) + { + throw new ServiceUnavailableException($"Payment-Service call failed ({(int)response.StatusCode}): {responseBody}"); + } + + return responseBody; + } + + // Must match Payment-Service's Middleware/HmacValidationMiddleware.ComputeSignature + // exactly: METHOD\nPATH\nTIMESTAMP\nBODY, HMACSHA256, hex lowercase. + private static string ComputeSignature(string secret, string method, string path, string timestamp, string body) + { + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); + var payload = $"{method}\n{path}\n{timestamp}\n{body}"; + var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload)); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + } +} diff --git a/Services/PaymentService.cs b/Services/PaymentService.cs new file mode 100644 index 0000000..8a4ce03 --- /dev/null +++ b/Services/PaymentService.cs @@ -0,0 +1,691 @@ +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using Fitness_core_BE.Models.Enums; + +namespace Fitness_core_BE.Services.Payment +{ + public class PaymentService : IPaymentService + { + private readonly IPaymentRepository _paymentRepository; + private readonly ICustomerInvoiceRepository _customerInvoiceRepository; + private readonly IClientInvoiceRepository _clientInvoiceRepository; + private readonly ICustomerSubscriptionRepository _customerSubscriptionRepository; + private readonly ICustomerRepository _customerRepository; + private readonly IReceiptService _receiptService; + private readonly IThirdPartyService _thirdPartyService; + private readonly IAccessDeviceService _accessDeviceService; + private readonly InvoicePaymentTokenHelper _invoicePaymentTokenHelper; + private readonly IConfiguration _config; + private readonly UserRoleHelper _userRoleHelper; + private readonly IUnitOfWork _uow; + + public PaymentService( + IPaymentRepository paymentRepository, + ICustomerInvoiceRepository customerInvoiceRepository, + IClientInvoiceRepository clientInvoiceRepository, + ICustomerSubscriptionRepository customerSubscriptionRepository, + ICustomerRepository customerRepository, + IReceiptService receiptService, + IThirdPartyService thirdPartyService, + IAccessDeviceService accessDeviceService, + InvoicePaymentTokenHelper invoicePaymentTokenHelper, + IConfiguration config, + UserRoleHelper userRoleHelper, + IUnitOfWork uow) + { + _paymentRepository = paymentRepository; + _customerInvoiceRepository = customerInvoiceRepository; + _clientInvoiceRepository = clientInvoiceRepository; + _customerSubscriptionRepository = customerSubscriptionRepository; + _customerRepository = customerRepository; + _receiptService = receiptService; + _thirdPartyService = thirdPartyService; + _accessDeviceService = accessDeviceService; + _invoicePaymentTokenHelper = invoicePaymentTokenHelper; + _config = config; + _userRoleHelper = userRoleHelper; + _uow = uow; + } + + public async Task CreateCustomerPayment(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + Guid customerInvoiceId = DataExtractor.GetRequiredGuid(data, "customerInvoiceId"); + decimal payAmount = DataExtractor.GetRequiredDecimal(data, "payAmount"); + + var invoice = await _customerInvoiceRepository.GetCustomerInvoiceById(customerInvoiceId) + ?? throw new NotFoundException($"Customer invoice {customerInvoiceId} not found"); + + Guid? cardId = null; + var cardIdStr = DataExtractor.GetOptionalString(data, "cardId"); + if (!string.IsNullOrEmpty(cardIdStr) && Guid.TryParse(cardIdStr, out var parsedCardId)) + cardId = parsedCardId; + + var attempt = new PaymentTransaction + { + PayDate = DateTime.UtcNow, + PayStatus = "Pending", + PayAmount = payAmount, + PayNote = DataExtractor.GetOptionalString(data, "payNote"), + FK_CardId = cardId, + FK_CustomerInvoiceId = customerInvoiceId, + FK_ClientId = invoice.FKClientId, + FK_CustomerId = invoice.CustomerSubscription?.FKCustomerId, + BeneficiaryType = BeneficiaryType.Customer, + RefType = DataExtractor.GetOptionalString(data, "refType") ?? "invoice", + BankRefNo = DataExtractor.GetOptionalString(data, "bankRefNo"), + PaymentReferenceId = DataExtractor.GetOptionalString(data, "paymentReferenceId"), + IsActive = true, + IsApproved = false + }; + + var result = await _paymentRepository.CreatePaymentAttemptAsync(attempt); + await _uow.CommitAsync(); + + return new { success = true, message = "Payment attempt created", payment = result }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task CreateClientPayment(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + Guid clientInvoiceId = DataExtractor.GetRequiredGuid(data, "clientInvoiceId"); + decimal payAmount = DataExtractor.GetRequiredDecimal(data, "payAmount"); + + var invoice = await _clientInvoiceRepository.GetClientInvoiceById(clientInvoiceId) + ?? throw new NotFoundException($"Client invoice {clientInvoiceId} not found"); + + Guid? cardId = null; + var cardIdStr = DataExtractor.GetOptionalString(data, "cardId"); + if (!string.IsNullOrEmpty(cardIdStr) && Guid.TryParse(cardIdStr, out var parsedCardId)) + cardId = parsedCardId; + + var attempt = new PaymentTransaction + { + PayDate = DateTime.UtcNow, + PayStatus = "Pending", + PayAmount = payAmount, + PayNote = DataExtractor.GetOptionalString(data, "payNote"), + FK_CardId = cardId, + FK_ClientInvoiceId = clientInvoiceId, + FK_ClientId = invoice.ClientSubscription?.FKClientId, + BeneficiaryType = BeneficiaryType.Client, + RefType = DataExtractor.GetOptionalString(data, "refType") ?? "invoice", + BankRefNo = DataExtractor.GetOptionalString(data, "bankRefNo"), + PaymentReferenceId = DataExtractor.GetOptionalString(data, "paymentReferenceId"), + IsActive = true, + IsApproved = false + }; + + var result = await _paymentRepository.CreatePaymentAttemptAsync(attempt); + await _uow.CommitAsync(); + + return new { success = true, message = "Payment attempt created", payment = result }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task GetPaymentById(object payload, HttpContext httpContext) + { + try + { + var data = DeserializePayload.Deserialize(payload); + Guid attemptId = DataExtractor.GetRequiredGuid(data, "attemptId"); + + var payment = await _paymentRepository.GetPaymentByIdAsync(attemptId) + ?? throw new NotFoundException($"Payment {attemptId} not found"); + + return payment; + } + catch + { + throw; + } + } + + public async Task GetPaymentsByCustomerInvoice(object payload, HttpContext httpContext) + { + try + { + var data = DeserializePayload.Deserialize(payload); + Guid customerInvoiceId = DataExtractor.GetRequiredGuid(data, "customerInvoiceId"); + + var payments = await _paymentRepository.GetPaymentsByCustomerInvoiceIdAsync(customerInvoiceId); + return payments; + } + catch + { + throw; + } + } + + public async Task GetPaymentsByClientInvoice(object payload, HttpContext httpContext) + { + try + { + var data = DeserializePayload.Deserialize(payload); + Guid clientInvoiceId = DataExtractor.GetRequiredGuid(data, "clientInvoiceId"); + + var payments = await _paymentRepository.GetPaymentsByClientInvoiceIdAsync(clientInvoiceId); + return payments; + } + catch + { + throw; + } + } + + public async Task ApprovePayment(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + Guid attemptId = DataExtractor.GetRequiredGuid(data, "attemptId"); + + var payment = await _paymentRepository.GetPaymentByIdAsync(attemptId) + ?? throw new NotFoundException($"Payment {attemptId} not found"); + + payment.IsApproved = true; + payment.PayStatus = "Approved"; + + await _paymentRepository.UpdatePaymentAttemptAsync(payment); + + if (payment.FK_CustomerInvoiceId.HasValue) + { + // Roll the invoice status up from the approved total (Pending/Partial/Paid) + // and re-allocate the schedule, rather than blindly marking "Paid". + await _uow.SaveChangesAsync(); + await RollupCustomerInvoiceAsync(payment.FK_CustomerInvoiceId.Value); + } + else if (payment.FK_ClientInvoiceId.HasValue) + { + await _clientInvoiceRepository.UpdateClientInvoiceStatus(payment.FK_ClientInvoiceId.Value, "Paid"); + } + + await _uow.CommitAsync(); + + if (payment.FK_ClientInvoiceId.HasValue) + { + // Post-commit, best-effort: email/SMS the gym owner a receipt PDF for their + // platform-fee payment. Never let a notification failure affect the approval. + try + { + var receipt = await _receiptService.BuildForClientInvoiceAsync(payment.FK_ClientInvoiceId.Value, payment.PayAmount); + await _receiptService.SendReceiptAsync(receipt); + } + catch (Exception ex) + { + Console.WriteLine($"Client invoice receipt notification failed (non-critical): {ex.Message}"); + } + } + + return new { success = true, message = "Payment approved", payment }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + /// + /// Owner records a counter payment against a subscription. Immediately approved. + /// Fills the earliest unpaid installments; non-split plans must be settled in full. + /// + public async Task RecordSubscriptionPayment(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + Guid subscriptionId = DataExtractor.GetRequiredGuid(data, "subscriptionId"); + decimal amount = DataExtractor.GetRequiredDecimal(data, "amount"); + string? note = DataExtractor.GetOptionalString(data, "note"); + string paymentMethod = DataExtractor.GetRequiredString(data, "paymentMethod"); + string? bankRefNo = DataExtractor.GetOptionalString(data, "bankRefNo"); + + if (paymentMethod != "Cash" && paymentMethod != "Bank") + throw new ArgumentException("paymentMethod must be 'Cash' or 'Bank'"); + + if (amount <= 0) + throw new ArgumentException("Payment amount must be greater than zero"); + + var subscription = await _customerSubscriptionRepository.GetCustomerSubscriptionById(subscriptionId) + ?? throw new NotFoundException("Subscription not found"); + + var plan = subscription.CustomerPlan + ?? throw new NotFoundException("Subscription plan not found"); + + // Authorization: the subscription's plan must belong to the caller's gym. + Guid clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + if (plan.FKClientId != clientId) + throw new UnauthorizedException("This subscription does not belong to your gym"); + + var invoice = await _customerInvoiceRepository.GetInvoiceBySubscriptionIdAsync(subscriptionId); + if (invoice == null) + { + // GetInvoiceBySubscriptionIdAsync also returns null while a renewal invoice + // is awaiting approval (its only invoices are unapproved and/or RolledOver + // right now) -- that is not a legacy subscription, and self-healing here + // would silently create a duplicate, uncarried-forward invoice. Only + // self-heal a genuine "never had an invoice" subscription. + if (await _customerInvoiceRepository.HasPendingApprovalInvoiceAsync(subscriptionId)) + throw new BadRequestException( + "This member's renewal is awaiting approval. Approve, change plan, or reject it before recording a payment."); + + // Self-heal legacy subscriptions that predate automatic invoicing. + invoice = await _customerInvoiceRepository.CreateCustomerInvoiceFromSubscription(subscriptionId); + await _uow.SaveChangesAsync(); + } + + var alreadyPaid = await _paymentRepository.GetApprovedPaidAmountAsync(invoice.CustomerInvoiceId); + var balance = invoice.Amount - alreadyPaid; + + if (amount > balance) + throw new ArgumentException($"Amount exceeds the outstanding balance ({balance:0.##})"); + if (!plan.AllowSplitPayments && amount != balance) + throw new ArgumentException("This plan must be paid in full; split payments are not enabled"); + + var attempt = new PaymentTransaction + { + PayDate = DateTime.UtcNow, + PayStatus = "Approved", + PayAmount = amount, + PayNote = note, + FK_CustomerInvoiceId = invoice.CustomerInvoiceId, + FK_ClientId = invoice.FKClientId, + FK_CustomerId = subscription.FKCustomerId, + BeneficiaryType = BeneficiaryType.Customer, + RefType = "manual", + PaymentMethod = paymentMethod, + BankRefNo = paymentMethod == "Bank" ? bankRefNo : null, + IsActive = true, + IsApproved = true + }; + + var result = await _paymentRepository.CreatePaymentAttemptAsync(attempt); + await _uow.SaveChangesAsync(); + + var summary = await RollupCustomerInvoiceAsync(invoice.CustomerInvoiceId); + await _uow.CommitAsync(); + + // Post-commit, best-effort: text the member and email them a receipt PDF. + // Never let a notification failure affect the recorded payment. + try + { + var receipt = await _receiptService.BuildForInvoiceAsync(invoice.CustomerInvoiceId, amount); + await _receiptService.SendReceiptAsync(receipt); + } + catch (Exception ex) + { + Console.WriteLine($"Receipt notification failed (non-critical): {ex.Message}"); + } + + // Post-commit, best-effort: once the invoice is fully settled, undo whatever + // overdue lock put the member's fingerprint access on hold -- the owner just + // collected the money, they shouldn't also have to remember to hit the separate + // unlock endpoint. SetCustomerAccessAsync already carries this onto every + // co-member on a shared group/couple subscription, not just the payer. + if (summary.Status == "Paid") + { + try + { + var customer = await _customerRepository.GetCustomerById(subscription.FKCustomerId); + if (customer != null) + { + var accessStatus = await _accessDeviceService.GetAccessStatusAsync(clientId, customer.FKUserId); + if (accessStatus?.DesiredAccessState == "Disabled") + await _accessDeviceService.SetCustomerAccessAsync(clientId, subscription.FKCustomerId, enabled: true, reason: null); + } + } + catch (Exception ex) + { + Console.WriteLine($"Auto-unlock after payment failed (non-critical): {ex.Message}"); + } + } + + return new + { + success = true, + invoiceId = invoice.CustomerInvoiceId, + invoiceAmount = summary.InvoiceAmount, + paidAmount = summary.Paid, + balance = summary.Balance, + status = summary.Status, + payment = result + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task GetSubscriptionPayments(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + Guid subscriptionId = DataExtractor.GetRequiredGuid(data, "subscriptionId"); + + var subscription = await _customerSubscriptionRepository.GetCustomerSubscriptionById(subscriptionId) + ?? throw new NotFoundException("Subscription not found"); + + Guid clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + if (subscription.CustomerPlan == null || subscription.CustomerPlan.FKClientId != clientId) + throw new UnauthorizedException("This subscription does not belong to your gym"); + + var invoice = await _customerInvoiceRepository.GetInvoiceBySubscriptionIdAsync(subscriptionId); + decimal invoiceAmount = invoice?.Amount ?? + subscription.CustomerPlan.PlanPrice + + (subscription.CustomerPlan.HasOnboardingFee ? subscription.CustomerPlan.OnboardingFeeAmount : 0m); + var payments = invoice != null + ? await _paymentRepository.GetPaymentsByCustomerInvoiceIdAsync(invoice.CustomerInvoiceId) + : new List(); + var installments = await _customerSubscriptionRepository.GetInstallmentsAsync(subscriptionId); + + decimal paid = payments.Where(p => p.IsApproved == true).Sum(p => p.PayAmount); + + return new + { + invoiceId = invoice?.CustomerInvoiceId, + invoiceAmount, + paidAmount = paid, + balance = invoiceAmount - paid, + status = paid >= invoiceAmount && invoiceAmount > 0 ? "Paid" : paid > 0 ? "Partial" : "Pending", + installments = installments.Select(i => new + { + i.SubscriptionInstallmentId, + i.InstallmentNo, + i.DueDate, + i.Amount, + i.PaidAmount, + i.Status + }), + payments = payments.Select(p => new + { + p.AttemptId, + p.PayDate, + p.PayStatus, + p.PayAmount, + p.PayNote, + p.RefType, + p.PaymentMethod, + p.BankRefNo, + p.IsApproved + }) + }; + } + + /// + /// Owner-triggered "Charge now" against the member's consented auto-charge card. + /// No live payment gateway is wired up anywhere in this backend yet (see + /// ActivationService's own deferred platform-billing charge) — this records the attempt + /// and leaves it Pending, exactly like the rest of the app defers real settlement. Replace + /// the body of this method with a real PayHere/Stripe charge call once a gateway exists; + /// until then the owner falls back to SendPaymentLink or RecordSubscriptionPayment. + /// + public async Task AttemptAutoCharge(Guid customerInvoiceId, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var invoice = await _customerInvoiceRepository.GetCustomerInvoiceById(customerInvoiceId) + ?? throw new NotFoundException("Invoice not found"); + + Guid clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + if (invoice.FKClientId != clientId) + throw new UnauthorizedException("This invoice does not belong to your gym"); + + var subscription = await _customerSubscriptionRepository.GetCustomerSubscriptionById(invoice.FKCustomerSubscriptionId) + ?? throw new NotFoundException("Subscription not found"); + + if (!subscription.AutoChargeEnabled || subscription.FKAutoChargeCardId == null) + throw new BadRequestException("Auto-deduct isn't enabled for this member. Send a payment link or mark as paid instead."); + + var card = await _customerRepository.GetCardByIdAsync(subscription.FKAutoChargeCardId.Value); + if (card == null || !card.ConsentGiven) + throw new BadRequestException("This member hasn't consented to auto-deduct. Send a payment link or mark as paid instead."); + + var alreadyPaid = await _paymentRepository.GetApprovedPaidAmountAsync(invoice.CustomerInvoiceId); + var balance = invoice.Amount - alreadyPaid; + if (balance <= 0) + throw new BadRequestException("This invoice is already fully paid."); + + var attempt = new PaymentTransaction + { + PayDate = DateTime.UtcNow, + PayStatus = "Pending", + PayAmount = balance, + FK_CardId = card.JobId, + FK_CustomerInvoiceId = invoice.CustomerInvoiceId, + FK_ClientId = invoice.FKClientId, + FK_CustomerId = subscription.FKCustomerId, + BeneficiaryType = BeneficiaryType.Customer, + RefType = "auto", + IsActive = true, + IsApproved = false + }; + + var result = await _paymentRepository.CreatePaymentAttemptAsync(attempt); + await _uow.CommitAsync(); + + return new + { + success = true, + status = "Pending", + message = "Charge attempt recorded — pending confirmation.", + payment = result + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + /// + /// Emails/texts the member a link to the public pay-by-link page so they can pay this + /// invoice online themselves — the other fallback alongside AttemptAutoCharge/RecordSubscriptionPayment. + /// + public async Task SendPaymentLink(Guid customerInvoiceId, HttpContext httpContext) + { + Guid clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + var model = await _receiptService.BuildForInvoiceAsync(customerInvoiceId, requireClientId: clientId); + + if (model.Balance <= 0) + throw new BadRequestException("This invoice is already fully paid."); + if (string.IsNullOrWhiteSpace(model.MemberEmail) && string.IsNullOrWhiteSpace(model.MemberPhone)) + throw new BadRequestException("This member has no phone or email on file to send the link to."); + + string token = _invoicePaymentTokenHelper.Generate(customerInvoiceId); + string baseUrl = (_config["App:FrontendBaseUrl"] ?? string.Empty).TrimEnd('/'); + string link = $"{baseUrl}/pay/{token}"; + + bool emailSent = false; + bool smsSent = false; + + if (!string.IsNullOrWhiteSpace(model.MemberEmail)) + { + try + { + string html = + $"

Hi {model.MemberName},

" + + $"

You have an outstanding balance of {model.Currency} {model.Balance:N2} for {model.PlanName} at {model.GymName}.

" + + $"

Pay now

" + + $"

— {model.GymName}
powered by GYM OS

"; + await _thirdPartyService.SendEmailAsync(model.MemberEmail!, $"Payment link — {model.GymName}", html); + emailSent = true; + } + catch (Exception ex) + { + Console.WriteLine($"Payment link email failed (non-critical): {ex.Message}"); + } + } + + if (!string.IsNullOrWhiteSpace(model.MemberPhone)) + { + try + { + string sms = $"Pay {model.Currency} {model.Balance:N2} for {model.PlanName} at {model.GymName}: {link}"; + var smsResult = await _thirdPartyService.SendSmsAsync(model.MemberPhone!, message: sms); + smsSent = smsResult.StatusCode is >= 200 and < 300; + } + catch (Exception ex) + { + Console.WriteLine($"Payment link SMS failed (non-critical): {ex.Message}"); + } + } + + if (!emailSent && !smsSent) + throw new BadRequestException("Couldn't send the payment link by email or SMS. Check that notifications are configured."); + + string via = emailSent && smsSent ? "email and SMS" : emailSent ? "email" : "SMS"; + return new { success = true, message = $"Payment link sent via {via}" }; + } + + public async Task GetInvoiceByPaymentToken(string token) + { + var invoiceId = _invoicePaymentTokenHelper.ValidateAndGetInvoiceId(token) + ?? throw new NotFoundException("This payment link is invalid or has expired."); + + var model = await _receiptService.BuildForInvoiceAsync(invoiceId); + + return new + { + memberName = model.MemberName, + gymName = model.GymName, + planName = model.PlanName, + amount = model.InvoiceAmount, + balance = model.Balance, + currency = model.Currency + }; + } + + /// + /// Public, one-time self-serve payment from the pay-by-link page. Since the member is + /// completing this live (not a background auto-charge), it's approved immediately — same + /// treatment as RecordSubscriptionPayment. cardToken is a mock value today; this is the + /// seam where a real gateway checkout replaces the stub later. + /// + public async Task ChargeInvoiceByPaymentToken(string token, object payload) + { + var invoiceId = _invoicePaymentTokenHelper.ValidateAndGetInvoiceId(token) + ?? throw new NotFoundException("This payment link is invalid or has expired."); + + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + DataExtractor.GetRequiredString(data, "cardToken"); + + var invoice = await _customerInvoiceRepository.GetCustomerInvoiceById(invoiceId) + ?? throw new NotFoundException("Invoice not found"); + + var alreadyPaid = await _paymentRepository.GetApprovedPaidAmountAsync(invoice.CustomerInvoiceId); + var balance = invoice.Amount - alreadyPaid; + if (balance <= 0) + throw new BadRequestException("This invoice is already fully paid."); + + var attempt = new PaymentTransaction + { + PayDate = DateTime.UtcNow, + PayStatus = "Approved", + PayAmount = balance, + PayNote = "Paid online via payment link", + FK_CustomerInvoiceId = invoice.CustomerInvoiceId, + FK_ClientId = invoice.FKClientId, + FK_CustomerId = invoice.CustomerSubscription?.FKCustomerId, + BeneficiaryType = BeneficiaryType.Customer, + RefType = "link", + IsActive = true, + IsApproved = true + }; + + await _paymentRepository.CreatePaymentAttemptAsync(attempt); + await _uow.SaveChangesAsync(); + + var summary = await RollupCustomerInvoiceAsync(invoice.CustomerInvoiceId); + await _uow.CommitAsync(); + + try + { + var receipt = await _receiptService.BuildForInvoiceAsync(invoice.CustomerInvoiceId, balance); + await _receiptService.SendReceiptAsync(receipt); + } + catch (Exception ex) + { + Console.WriteLine($"Receipt notification failed (non-critical): {ex.Message}"); + } + + return new + { + success = true, + invoiceId = invoice.CustomerInvoiceId, + paidAmount = summary.Paid, + balance = summary.Balance, + status = summary.Status + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + /// + /// Recomputes an invoice's status from its approved payment total and + /// re-allocates the installment schedule (idempotent — always derived from the total). + /// + private async Task<(decimal InvoiceAmount, decimal Paid, decimal Balance, string Status)> RollupCustomerInvoiceAsync(Guid customerInvoiceId) + { + var invoice = await _customerInvoiceRepository.GetCustomerInvoiceById(customerInvoiceId) + ?? throw new NotFoundException("Invoice not found"); + + decimal paid = await _paymentRepository.GetApprovedPaidAmountAsync(customerInvoiceId); + string status = paid >= invoice.Amount && invoice.Amount > 0 + ? "Paid" + : paid > 0 ? "Partial" : "Pending"; + + await _customerInvoiceRepository.UpdateCustomerInvoiceStatus(customerInvoiceId, status); + + // Distribute the total across installments in order. + var installments = await _customerSubscriptionRepository.GetInstallmentsAsync(invoice.FKCustomerSubscriptionId); + decimal remaining = paid; + foreach (var installment in installments) + { + decimal fill = Math.Min(remaining, installment.Amount); + installment.PaidAmount = fill; + installment.Status = fill >= installment.Amount + ? "Paid" + : fill > 0 ? "Partial" : "Pending"; + remaining -= fill; + } + + return (invoice.Amount, paid, invoice.Amount - paid, status); + } + } +} diff --git a/Services/PaymentServiceClient.cs b/Services/PaymentServiceClient.cs new file mode 100644 index 0000000..549a553 --- /dev/null +++ b/Services/PaymentServiceClient.cs @@ -0,0 +1,104 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Fitness_core_BE.DTOs.PaymentGateways; +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Interfaces.Services; + +namespace Fitness_core_BE.Services +{ + public class PaymentServiceClient : IPaymentServiceClient + { + private readonly HttpClient _httpClient; + private readonly string _baseUrl; + private readonly string _internalKey; + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + + public PaymentServiceClient(IHttpClientFactory httpClientFactory, IConfiguration configuration) + { + _httpClient = httpClientFactory.CreateClient(); + _baseUrl = (configuration["PaymentService:BaseUrl"] ?? "http://127.0.0.1:5120").TrimEnd('/'); + _internalKey = configuration["PaymentService:InternalApiKey"] ?? string.Empty; + } + + public async Task> ListGatewaysAsync(CancellationToken cancellationToken = default) + { + var response = await SendAsync(HttpMethod.Get, "/api/v1/gateways", null, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + return JsonSerializer.Deserialize>(body, JsonOptions) ?? new List(); + } + + public async Task UpsertGatewayAsync(UpsertGatewayDefinitionRequest request, CancellationToken cancellationToken = default) + { + var response = await SendAsync(HttpMethod.Post, "/api/v1/gateways", request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + return JsonSerializer.Deserialize(body, JsonOptions) + ?? throw new ServiceUnavailableException("Payment-Service returned an empty gateway response."); + } + + public async Task SetGatewayEnabledAsync(Guid id, bool isEnabled, CancellationToken cancellationToken = default) + { + var response = await SendAsync(HttpMethod.Put, $"/api/v1/gateways/{id}/enabled", new { isEnabled }, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + return JsonSerializer.Deserialize(body, JsonOptions) + ?? throw new ServiceUnavailableException("Payment-Service returned an empty gateway response."); + } + + public async Task UpsertPlatformCredentialAsync(UpsertPlatformGatewayCredentialRequest request, CancellationToken cancellationToken = default) + { + await SendAsync( + HttpMethod.Put, + $"/api/v1/gateways/platforms/{request.PlatformId}/credentials/{request.GatewayName}", + new { credentialsJson = request.CredentialsJson }, + cancellationToken); + } + + public async Task<(Guid PlatformId, string ApiKey, string HmacSecret)> ProvisionPlatformAsync( + Guid clientId, string displayName, CancellationToken cancellationToken = default) + { + var response = await SendAsync( + HttpMethod.Post, + "/api/v1/platforms", + new { externalClientId = clientId, displayName }, + cancellationToken); + + var body = await response.Content.ReadAsStringAsync(cancellationToken); + using var doc = JsonDocument.Parse(body); + var platformId = doc.RootElement.GetProperty("platformId").GetGuid(); + var apiKey = doc.RootElement.GetProperty("apiKey").GetString() ?? string.Empty; + var hmacSecret = doc.RootElement.GetProperty("hmacSecret").GetString() ?? string.Empty; + + return (platformId, apiKey, hmacSecret); + } + + private async Task SendAsync(HttpMethod method, string path, object? body, CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(method, $"{_baseUrl}{path}"); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Add("X-Internal-Key", _internalKey); + + if (body is not null) + { + request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); + } + + HttpResponseMessage response; + try + { + response = await _httpClient.SendAsync(request, cancellationToken); + } + catch (HttpRequestException ex) + { + throw new ServiceUnavailableException("Could not reach Payment-Service.", ex); + } + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(cancellationToken); + throw new ServiceUnavailableException($"Payment-Service call failed ({(int)response.StatusCode}): {errorBody}"); + } + + return response; + } + } +} diff --git a/Services/PaymentsEnablementService.cs b/Services/PaymentsEnablementService.cs new file mode 100644 index 0000000..8b752ba --- /dev/null +++ b/Services/PaymentsEnablementService.cs @@ -0,0 +1,187 @@ +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.DTOs.PaymentsEnablement; +using Fitness_core_BE.DTOs.PaymentGateways; +using Fitness_core_BE.Models; +using Fitness_core_BE.Models.Enums; + +namespace Fitness_core_BE.Services +{ + public class PaymentsEnablementService : IPaymentsEnablementService + { + private readonly IPaymentsEnablementRepository _paymentsRepository; + private readonly IClientRepository _clientRepository; + private readonly IPaymentServiceClient _paymentServiceClient; + private readonly UserRoleHelper _userRoleHelper; + private readonly IUnitOfWork _uow; + + public PaymentsEnablementService( + IPaymentsEnablementRepository paymentsRepository, + IClientRepository clientRepository, + IPaymentServiceClient paymentServiceClient, + UserRoleHelper userRoleHelper, + IUnitOfWork uow) + { + _paymentsRepository = paymentsRepository; + _clientRepository = clientRepository; + _paymentServiceClient = paymentServiceClient; + _userRoleHelper = userRoleHelper; + _uow = uow; + } + + public Task> GetTrackOptionsAsync() + { + return Task.FromResult(new List + { + new(PaymentTrack.OwnGateway.ToString(), new { description = "Use your own PayHere/Stripe merchant account.", feePercentage = 0 }), + new(PaymentTrack.HexaFitness.ToString(), new { description = "Use HexaFitness's payment gateway; requires KYC before first payout.", feePercentage = 2.5 }) + }); + } + + public async Task SelectTrackAsync(HttpContext httpContext, SelectPaymentTrackRequest request) + { + var userId = DataExtractor.GetUserIdFromContext(httpContext); + var clientId = await _userRoleHelper.GetClientId(userId); + + if (!Enum.TryParse(request.Track, true, out var track)) + throw new BadRequestException($"Unknown payment track '{request.Track}'."); + + var client = await _clientRepository.GetClientEntityByIdAsync(clientId) + ?? throw new NotFoundException("Client not found."); + + client.PaymentTrack = track; + await _uow.SaveChangesAsync(); + + return new PaymentTrackSelectionResult(track.ToString(), track == PaymentTrack.HexaFitness); + } + + public async Task> GetAvailableGatewaysAsync( + CancellationToken ct = default) + { + return (await _paymentServiceClient.ListGatewaysAsync(ct)) + .Where(gateway => gateway.IsEnabled) + .OrderByDescending(gateway => gateway.IsDefault) + .ThenBy(gateway => gateway.DisplayName) + .ToList(); + } + + public async Task SubmitKycAsync( + HttpContext httpContext, + SubmitGatewayOnboardingRequest request, + CancellationToken ct = default) + { + var userId = DataExtractor.GetUserIdFromContext(httpContext); + var clientId = await _userRoleHelper.GetClientId(userId); + + var gateway = (await GetAvailableGatewaysAsync(ct)) + .FirstOrDefault(item => item.Id == request.GatewayId) + ?? throw new BadRequestException("Select an available payment gateway."); + + var client = await _clientRepository.GetClientEntityByIdAsync(clientId) + ?? throw new NotFoundException("Client not found."); + + var current = await _paymentsRepository.GetKycByClientIdAsync(clientId, ct); + if (current?.KycStatus == KycStatus.Pending) + throw new BadRequestException("A gateway KYC request is already pending review."); + if (current?.KycStatus == KycStatus.Approved) + throw new BadRequestException("Gateway KYC is already approved for this client."); + + await _uow.BeginAsync(); + try + { + // Catalog gateways are platform-managed, so this follows the + // HexaFitness payment track rather than client-owned credentials. + client.PaymentTrack = PaymentTrack.HexaFitness; + + var kyc = new ClientPayoutKyc + { + Id = Guid.NewGuid(), + FKClientId = clientId, + SelectedGatewayId = gateway.Id, + SelectedGatewayName = gateway.DisplayName, + KycStatus = KycStatus.Pending, + SubmittedAt = DateTime.UtcNow + }; + + await _paymentsRepository.CreateKycAsync(kyc, ct); + await _uow.CommitAsync(); + + return ToDto(kyc, client); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task GetKycStatusAsync(HttpContext httpContext) + { + var userId = DataExtractor.GetUserIdFromContext(httpContext); + var clientId = await _userRoleHelper.GetClientId(userId); + var kyc = await _paymentsRepository.GetKycByClientIdAsync(clientId); + return kyc is null + ? new GatewayOnboardingStatusDto("NotSubmitted", null, null, null) + : new GatewayOnboardingStatusDto( + kyc.KycStatus.ToString(), + kyc.SelectedGatewayId, + kyc.SelectedGatewayName, + kyc.RejectionReason); + } + + public async Task GetWalletAsync(HttpContext httpContext) + { + var userId = DataExtractor.GetUserIdFromContext(httpContext); + var clientId = await _userRoleHelper.GetClientId(userId); + var wallet = await _paymentsRepository.GetWalletByClientIdAsync(clientId) + ?? throw new NotFoundException("Wallet not found for this client."); + + var ledger = await _paymentsRepository.GetLedgerAsync(wallet.Id); + + return new WalletDto( + wallet.Balance, + wallet.Currency, + ledger.Select(e => new WalletLedgerEntryDto(e.Id, e.EntryType.ToString(), e.Amount, e.Description, e.CreatedAt)).ToList(), + wallet.LastPayoutDate); + } + + public async Task GetPayoutScheduleAsync(HttpContext httpContext) + { + var userId = DataExtractor.GetUserIdFromContext(httpContext); + var clientId = await _userRoleHelper.GetClientId(userId); + var wallet = await _paymentsRepository.GetWalletByClientIdAsync(clientId) + ?? throw new NotFoundException("Wallet not found for this client."); + + var today = DateTime.UtcNow.Date; + var daysUntilTuesday = ((int)DayOfWeek.Tuesday - (int)today.DayOfWeek + 7) % 7; + daysUntilTuesday = daysUntilTuesday == 0 ? 7 : daysUntilTuesday; + var nextPayoutDate = today.AddDays(daysUntilTuesday); + + return new PayoutScheduleDto(nextPayoutDate, "Weekly (Tuesday)", wallet.Balance); + } + + + // NIC fields used to come from the multi-gym onboarding wizard's OwnerActivation record; + // that flow no longer exists for a single already-real club (see PayoutKycReviewService's + // ToDto for the same note) -- always null here now. + private static ClientPayoutKycDto ToDto( + ClientPayoutKyc kyc, + Client client) + => new( + kyc.Id, + kyc.FKClientId, + client.ClientName, + kyc.SelectedGatewayId, + kyc.SelectedGatewayName, + kyc.KycStatus.ToString(), + kyc.SubmittedAt, + kyc.ReviewedAt, + kyc.RejectionReason, + null, + null, + null); + } +} diff --git a/Services/PayoutKycReviewService.cs b/Services/PayoutKycReviewService.cs new file mode 100644 index 0000000..bd100b3 --- /dev/null +++ b/Services/PayoutKycReviewService.cs @@ -0,0 +1,114 @@ +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.DTOs.PaymentsEnablement; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models.Enums; + +namespace Fitness_core_BE.Services +{ + public class PayoutKycReviewService : IPayoutKycReviewService + { + private readonly IPaymentsEnablementRepository _paymentsRepository; + private readonly UserRoleHelper _userRoleHelper; + private readonly IUnitOfWork _uow; + + public PayoutKycReviewService(IPaymentsEnablementRepository paymentsRepository, UserRoleHelper userRoleHelper, IUnitOfWork uow) + { + _paymentsRepository = paymentsRepository; + _userRoleHelper = userRoleHelper; + _uow = uow; + } + + public async Task> ListAsync(string? status) + { + var (items, _) = await _paymentsRepository.ListKycAsync(null); + var latestPerClient = items + .GroupBy(item => item.FKClientId) + .Select(group => group + .OrderByDescending(item => item.SubmittedAt) + .ThenByDescending(item => item.Id) + .First()); + + if (!string.IsNullOrWhiteSpace(status)) + { + if (!Enum.TryParse(status, true, out var parsedStatus)) + throw new BadRequestException($"Unknown KYC status '{status}'."); + latestPerClient = latestPerClient + .Where(item => item.KycStatus == parsedStatus); + } + + return latestPerClient + .OrderByDescending(item => item.SubmittedAt) + .Select(ToDto) + .ToList(); + } + + public async Task GetByClientIdAsync(Guid clientId) + { + var kyc = await _paymentsRepository.GetKycByClientIdAsync(clientId) + ?? throw new NotFoundException("No KYC submission found for this client."); + return ToDto(kyc); + } + + public async Task ReviewAsync(Guid clientId, ReviewPayoutKycRequest request, HttpContext httpContext) + { + var kyc = await _paymentsRepository.GetKycForUpdateAsync(clientId) + ?? throw new NotFoundException("No KYC submission found for this client."); + + if (!Enum.TryParse(request.Status, true, out var newStatus)) + throw new BadRequestException($"Unknown KYC status '{request.Status}'."); + if (newStatus is not KycStatus.Approved and not KycStatus.Rejected) + throw new BadRequestException("KYC can only be approved or rejected."); + if (kyc.KycStatus != KycStatus.Pending) + throw new BadRequestException("Only pending KYC submissions can be reviewed."); + if (newStatus == KycStatus.Rejected && string.IsNullOrWhiteSpace(request.Reason)) + throw new BadRequestException("A rejection reason is required."); + + var adminUserId = DataExtractor.GetUserIdFromContext(httpContext); + var adminId = await _userRoleHelper.GetAdminId(adminUserId) + ?? throw new ForbiddenException("Only admins may review payout KYC."); + + await _uow.BeginAsync(); + try + { + kyc.KycStatus = newStatus; + kyc.ReviewedAt = DateTime.UtcNow; + kyc.ReviewedByAdminId = adminId; + kyc.RejectionReason = newStatus == KycStatus.Rejected + ? request.Reason!.Trim() + : null; + + await _uow.CommitAsync(); + return ToDto(kyc); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + // NIC fields used to come from the multi-gym onboarding wizard's OwnerActivation record + // (collected during signup); that flow no longer exists for a single already-real club, + // so these are always null here now. If gateway KYC needs NIC info going forward, collect + // it directly on the KYC submission instead of reintroducing that dependency. + private static ClientPayoutKycDto ToDto(Models.ClientPayoutKyc kyc) + { + return new ClientPayoutKycDto( + kyc.Id, + kyc.FKClientId, + kyc.Client?.ClientName, + kyc.SelectedGatewayId, + kyc.SelectedGatewayName, + kyc.KycStatus.ToString(), + kyc.SubmittedAt, + kyc.ReviewedAt, + kyc.RejectionReason, + null, + null, + null); + } + } +} diff --git a/Services/PermissionService.cs b/Services/PermissionService.cs new file mode 100644 index 0000000..f460e2c --- /dev/null +++ b/Services/PermissionService.cs @@ -0,0 +1,63 @@ +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Services +{ + public class PermissionService : IPermissionService + { + private readonly UserRoleHelper _userRoleHelper; + private readonly IStaffRepository _staffRepository; + private readonly IGymRoleRepository _gymRoleRepository; + private readonly INavItemRepository _navItemRepository; + + public PermissionService( + UserRoleHelper userRoleHelper, + IStaffRepository staffRepository, + IGymRoleRepository gymRoleRepository, + INavItemRepository navItemRepository) + { + _userRoleHelper = userRoleHelper; + _staffRepository = staffRepository; + _gymRoleRepository = gymRoleRepository; + _navItemRepository = navItemRepository; + } + + public async Task GetPermissionContextAsync(HttpContext httpContext) + { + var userId = DataExtractor.GetUserIdFromContext(httpContext); + + // Owner check first: the gym owner always has full access and never touches + // GymRole/GymRolePermission at all. + var clientId = await _userRoleHelper.GetOwnedClientId(userId); + + if (clientId.HasValue) + { + var allKeys = (await _navItemRepository.GetActiveNavItemsOrdered()) + .Select(n => n.Key) + .ToHashSet(); + return new PermissionContext(IsOwner: true, ClientId: clientId, StaffId: null, AllowedNavKeys: allKeys); + } + + var staffId = await _userRoleHelper.GetStaffId(userId); + if (staffId.HasValue) + { + var staff = await _staffRepository.GetStaffById(staffId.Value); + if (staff?.FKGymRoleId is Guid gymRoleId && staff.GymRole?.IsActive == true) + { + var keys = (await _gymRoleRepository.GetPermissionKeys(gymRoleId)).ToHashSet(); + return new PermissionContext(IsOwner: false, ClientId: null, StaffId: staffId, AllowedNavKeys: keys); + } + + // Staff with no role assigned yet: dashboard only, not zero -- so forgetting + // to assign a role (the create-staff flow makes it optional) leaves someone + // with a blank-but-working dashboard instead of a fully broken login. + return new PermissionContext(IsOwner: false, ClientId: null, StaffId: staffId, AllowedNavKeys: new HashSet { "dashboard" }); + } + + // Neither owner nor staff (e.g. Customer/Admin token hitting a client-scoped endpoint). + return new PermissionContext(IsOwner: false, ClientId: null, StaffId: null, AllowedNavKeys: new HashSet()); + } + } +} diff --git a/Services/PlaceService.cs b/Services/PlaceService.cs new file mode 100644 index 0000000..d7c6328 --- /dev/null +++ b/Services/PlaceService.cs @@ -0,0 +1,175 @@ +using Fitness_core_BE.DTOs.Place; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using System.Text.Json; + +namespace Fitness_core_BE.Services +{ + public class PlaceService : IPlaceService + { + private readonly IPlaceRepo _placeRepo; + private readonly IUnitOfWork _uow; + private readonly UserRoleHelper _userRoleHelper; + + public PlaceService(IPlaceRepo placeRepo, IUnitOfWork uow, UserRoleHelper userRoleHelper) + { + _placeRepo = placeRepo; + _uow = uow; + _userRoleHelper = userRoleHelper; + } + + public async Task GetAllPlaces(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + bool includeInactive = data.ContainsKey("includeInactive") && data["includeInactive"].GetBoolean(); + + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + var places = await _placeRepo.GetAllPlaces(clientId, includeInactive); + + var devicesByPlace = await _placeRepo.GetDevicesByPlaceIdsAsync(places.Select(p => p.PlaceId).ToList()); + + return places.Select(p => MapToDto(p, devicesByPlace.GetValueOrDefault(p.PlaceId))).ToList(); + } + + public async Task CreatePlace(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + if (!data.ContainsKey("name")) throw new KeyNotFoundException("name is required"); + + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + + var place = new Place + { + FKClientId = clientId, + Name = data["name"].GetString() ?? "", + Description = data.ContainsKey("description") ? data["description"].GetString() : null + }; + + var result = await _placeRepo.CreatePlace(place); + await _uow.CommitAsync(); + + return MapToDto(result, null); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task UpdatePlace(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + if (!data.ContainsKey("placeId")) throw new KeyNotFoundException("placeId is required"); + + var placeId = Guid.Parse(data["placeId"].GetString()!); + var existing = await _placeRepo.GetPlaceById(placeId) + ?? throw new KeyNotFoundException("Place not found"); + + if (data.ContainsKey("name")) existing.Name = data["name"].GetString() ?? existing.Name; + if (data.ContainsKey("description")) existing.Description = data["description"].GetString(); + if (data.ContainsKey("isActive")) existing.IsActive = data["isActive"].GetBoolean(); + + var result = await _placeRepo.UpdatePlace(existing); + await _uow.CommitAsync(); + + var devicesByPlace = await _placeRepo.GetDevicesByPlaceIdsAsync(new List { result.PlaceId }); + return MapToDto(result, devicesByPlace.GetValueOrDefault(result.PlaceId)); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task SetPlaceStatus(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + var placeId = Guid.Parse(data["placeId"].GetString()!); + var isActive = data["isActive"].GetBoolean(); + + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + var place = await _placeRepo.SetPlaceActive(placeId, clientId, isActive) + ?? throw new KeyNotFoundException("Place not found"); + + await _uow.CommitAsync(); + + var devicesByPlace = await _placeRepo.GetDevicesByPlaceIdsAsync(new List { place.PlaceId }); + return MapToDto(place, devicesByPlace.GetValueOrDefault(place.PlaceId)); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task AssignDevice(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + if (!data.ContainsKey("placeId")) throw new KeyNotFoundException("placeId is required"); + + var placeId = Guid.Parse(data["placeId"].GetString()!); + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + + var place = await _placeRepo.GetPlaceById(placeId); + if (place == null || place.FKClientId != clientId) + throw new KeyNotFoundException("Place not found"); + + var hasDeviceId = data.ContainsKey("deviceId") && data["deviceId"].ValueKind != JsonValueKind.Null; + if (!hasDeviceId) + { + await _placeRepo.UnassignDeviceAsync(placeId); + } + else + { + var deviceId = Guid.Parse(data["deviceId"].GetString()!); + var device = await _placeRepo.GetDeviceByIdForClientAsync(deviceId, clientId) + ?? throw new KeyNotFoundException("Device not found"); + + if (await _placeRepo.IsDeviceAssignedToAnotherPlaceAsync(deviceId, placeId)) + throw new ArgumentException("This device is already assigned to another place."); + + await _placeRepo.AssignDeviceAsync(placeId, deviceId); + } + + await _uow.CommitAsync(); + + var devicesByPlace = await _placeRepo.GetDevicesByPlaceIdsAsync(new List { placeId }); + return MapToDto(place, devicesByPlace.GetValueOrDefault(placeId)); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + private static PlaceDto MapToDto(Place place, AccessDevice? device) => new() + { + PlaceId = place.PlaceId, + Name = place.Name, + Description = place.Description, + IsActive = place.IsActive, + CreatedAt = place.CreatedAt, + DeviceId = device?.DeviceId, + DeviceName = device?.DeviceName + }; + } +} diff --git a/Services/PlatformBillingService.cs b/Services/PlatformBillingService.cs new file mode 100644 index 0000000..e1494c9 --- /dev/null +++ b/Services/PlatformBillingService.cs @@ -0,0 +1,752 @@ +using Fitness_core_BE.DTOs.PaymentGateways; +using Fitness_core_BE.DTOs.PlatformBilling; +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using Fitness_core_BE.Models.Enums; + +namespace Fitness_core_BE.Services +{ + public class PlatformBillingService : IPlatformBillingService + { + // The gateway a gym's saved ClientCard was onboarded through — see + // ClientPaymentMethodService, which uses the same constant for onboarding. + private const string GatewayName = "DirectPay"; + + private readonly IPlatformBillingRepository _repository; + private readonly IClientStatRepository _clientStatRepository; + private readonly IAccessDeviceRepo _accessDeviceRepo; + private readonly IThirdPartyService _thirdPartyService; + private readonly IPaymentGatewayClient _paymentGatewayClient; + private readonly IPaymentRepository _paymentRepository; + private readonly PlatformInvoicePaymentTokenHelper _paymentTokenHelper; + private readonly IConfiguration _config; + private readonly IUnitOfWork _uow; + + public PlatformBillingService( + IPlatformBillingRepository repository, + IClientStatRepository clientStatRepository, + IAccessDeviceRepo accessDeviceRepo, + IThirdPartyService thirdPartyService, + IPaymentGatewayClient paymentGatewayClient, + IPaymentRepository paymentRepository, + PlatformInvoicePaymentTokenHelper paymentTokenHelper, + IConfiguration config, + IUnitOfWork uow) + { + _repository = repository; + _clientStatRepository = clientStatRepository; + _accessDeviceRepo = accessDeviceRepo; + _thirdPartyService = thirdPartyService; + _paymentGatewayClient = paymentGatewayClient; + _paymentRepository = paymentRepository; + _paymentTokenHelper = paymentTokenHelper; + _config = config; + _uow = uow; + } + + public async Task> GetPricingPlansAsync(CancellationToken ct = default) + { + var plans = await _repository.GetPricingPlansAsync(ct); + + return plans.Select(ToDto).ToList(); + } + + public async Task UpsertPricingPlanAsync(UpsertPricingPlanRequest request, CancellationToken ct = default) + { + await _uow.BeginAsync(); + try + { + ClientPackage plan; + if (request.ClientPackageId.HasValue) + { + plan = await _repository.GetPackageByIdAsync( + request.ClientPackageId.Value, ct) + ?? throw new NotFoundException("Pricing plan not found"); + } + else + { + plan = new ClientPackage { ClientPackageId = Guid.NewGuid() }; + await _repository.AddPackageAsync(plan, ct); + } + + plan.PackageName = request.PackageName; + plan.PackageDescription = request.PackageDescription; + plan.PerMemberRate = request.PerMemberRate; + plan.MinimumMonthlyBill = request.MinimumMonthlyBill; + plan.MonthlyCap = request.MonthlyCap; + plan.SetupFee = request.SetupFee; + plan.IncludedMemberCount = request.IncludedMemberCount; + plan.MaxBillableMemberCount = request.MaxBillableMemberCount; + plan.OwnGatewayFeePercent = request.OwnGatewayFeePercent; + plan.OwnGatewayFeeFixed = request.OwnGatewayFeeFixed; + plan.OurGatewayFeePercent = request.OurGatewayFeePercent; + plan.OurGatewayFeeFixed = request.OurGatewayFeeFixed; + plan.PayoutDayOfMonth = request.PayoutDayOfMonth is >= 1 and <= 28 ? request.PayoutDayOfMonth : 1; + plan.PackagePrice = request.MinimumMonthlyBill; + + if (request.IsDefault) + { + // Only one plan may be default at a time. + var currentDefaults = await _repository + .GetOtherDefaultPackagesAsync(plan.ClientPackageId, ct); + foreach (var d in currentDefaults) + d.IsDefault = false; + } + plan.IsDefault = request.IsDefault; + + await _uow.CommitAsync(); + return ToDto(plan); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task GetDevicePricingAsync(CancellationToken ct = default) + { + var config = await GetDeviceConfigOrDefaultAsync(ct); + return new DevicePricingDto + { + DeviceRentalFeePerMonth = config.DeviceRentalFeePerMonth, + DeviceInstallationFee = config.DeviceInstallationFee, + DeviceConfigureExistingFee = config.DeviceConfigureExistingFee + }; + } + + public async Task UpdateDevicePricingAsync(DevicePricingDto request, CancellationToken ct = default) + { + await _uow.BeginAsync(); + try + { + var config = await _repository.GetDevicePricingConfigAsync(ct); + if (config == null) + { + config = CreateDefaultDeviceConfig(); + await _repository.AddDevicePricingConfigAsync(config, ct); + } + config.DeviceRentalFeePerMonth = request.DeviceRentalFeePerMonth; + config.DeviceInstallationFee = request.DeviceInstallationFee; + config.DeviceConfigureExistingFee = request.DeviceConfigureExistingFee; + config.UpdatedAt = DateTime.UtcNow; + + await _uow.CommitAsync(); + return request; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task AssignPricingPlanAsync(Guid clientId, AssignPricingPlanRequest request, CancellationToken ct = default) + { + await _uow.BeginAsync(); + try + { + var plan = await _repository.GetPackageByIdAsync( + request.ClientPackageId, ct) + ?? throw new NotFoundException("Pricing plan not found"); + + var client = await _repository.GetClientByIdAsync(clientId, ct) + ?? throw new NotFoundException("Client not found"); + + var existingActive = await _repository + .GetActiveClientSubscriptionsAsync(clientId, ct); + foreach (var s in existingActive) + s.IsActive = false; + + await _repository.AddClientSubscriptionAsync(new ClientSubscription + { + SubscriptionId = Guid.NewGuid(), + FKClientId = clientId, + FKClientPackageId = request.ClientPackageId, + OnboardDate = DateTime.UtcNow, + IsActive = true + }, ct); + + if (!string.IsNullOrWhiteSpace(request.DeviceBillingMode)) + { + var existingDeviceBillings = await _repository + .GetActiveDeviceBillingsAsync(clientId, ct); + foreach (var d in existingDeviceBillings) + d.IsActive = false; + + await _repository.AddDeviceBillingAsync(new ClientDeviceBilling + { + Id = Guid.NewGuid(), + FKClientId = clientId, + Mode = request.DeviceBillingMode, + AgreedAmount = request.DeviceAgreedAmount ?? 0, + SetupFee = request.DeviceSetupFee ?? 0, + IsActive = true, + AssignedAt = DateTime.UtcNow + }, ct); + } + + await _uow.SaveChangesAsync(ct); + + // Combined onboarding payment: (machine full price OR setup-only for rental) + + // setup fee + minimum-member-subscription fee + gateway fee (may be 0). + var minMemberFee = plan.MinimumMonthlyBill; + var gatewayFee = ComputeGatewayFee(client, plan, minMemberFee); + + var extraItems = new List<(string Name, string Description, decimal Amount)>(); + if (!string.IsNullOrWhiteSpace(request.DeviceBillingMode)) + { + if (request.DeviceBillingMode == "FullPurchase") + { + extraItems.Add(("Access device (full purchase)", "One-time device purchase", request.DeviceAgreedAmount ?? 0)); + } + if ((request.DeviceSetupFee ?? 0) > 0) + { + extraItems.Add(("Device setup fee", "One-time installation/setup", request.DeviceSetupFee ?? 0)); + } + } + if (minMemberFee > 0) + { + extraItems.Add(("Minimum member subscription fee", $"{plan.PackageName ?? "Standard"} plan minimum bill", minMemberFee)); + } + if (gatewayFee > 0) + { + extraItems.Add(("Gateway fee", client.PaymentTrack == Models.Enums.PaymentTrack.OwnGateway ? "Own gateway fee" : "HexaFitness gateway fee", gatewayFee)); + } + + var invoice = await CreatePlatformInvoiceAsync(clientId, + "Plan setup", + $"Setup for the {plan.PackageName ?? "Standard"} plan", + plan.SetupFee, + extraItems, + ct); + + await _uow.CommitAsync(); + return invoice; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task ComputeMonthlyFeeAsync(Guid clientId, CancellationToken ct = default) + { + var client = await _repository.GetClientByIdAsync(clientId, ct) + ?? throw new NotFoundException("Client not found"); + var plan = await ResolvePlanForClientAsync(clientId, ct); + var activeMembers = await _clientStatRepository.GetTotalCustomerCountAsync(clientId, ct); + + var billableMembers = plan.MaxBillableMemberCount.HasValue + ? Math.Min(activeMembers, plan.MaxBillableMemberCount.Value) + : activeMembers; + var overageMembers = Math.Max(0, billableMembers - plan.IncludedMemberCount); + + decimal baseFee = plan.MinimumMonthlyBill + overageMembers * plan.PerMemberRate; + if (plan.MonthlyCap.HasValue) + baseFee = Math.Min(baseFee, plan.MonthlyCap.Value); + + var deviceFee = await ComputeDeviceFeeAsync(clientId, ct); + var gatewayFee = ComputeGatewayFee(client, plan, baseFee); + + return new PlatformFeeBreakdownDto + { + ClientId = clientId, + PlanName = plan.PackageName, + ActiveMemberCount = activeMembers, + PerMemberRate = plan.PerMemberRate, + MinimumMonthlyBill = plan.MinimumMonthlyBill, + MonthlyCap = plan.MonthlyCap, + BaseFee = baseFee, + DeviceCount = deviceFee.DeviceCount, + DeviceFee = deviceFee.Fee, + GatewayFee = gatewayFee, + TotalFee = baseFee + deviceFee.Fee + gatewayFee + }; + } + + public async Task GenerateMonthlyInvoiceAsync(Guid clientId, CancellationToken ct = default) + { + var breakdown = await ComputeMonthlyFeeAsync(clientId, ct); + + await _uow.BeginAsync(); + try + { + var extraItems = new List<(string, string, decimal)>(); + if (breakdown.DeviceCount > 0) + extraItems.Add(("Access device rental", $"{breakdown.DeviceCount} unit(s)", breakdown.DeviceFee)); + if (breakdown.GatewayFee > 0) + extraItems.Add(("Gateway fee", "Platform gateway fee for this billing period", breakdown.GatewayFee)); + + var invoice = await CreatePlatformInvoiceAsync(clientId, + $"Platform fee — {breakdown.ActiveMemberCount} active member(s)", + $"{breakdown.PlanName ?? "Standard"} plan — Rs. {breakdown.PerMemberRate}/member (min Rs. {breakdown.MinimumMonthlyBill})", + breakdown.BaseFee, + extraItems, + ct); + + await _uow.CommitAsync(); + return invoice; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task GenerateOnboardingInvoiceAsync(Guid clientId, CancellationToken ct = default) + { + var plan = await ResolvePlanForClientAsync(clientId, ct); + var devicePricing = await GetDeviceConfigOrDefaultAsync(ct); + var hasDevice = await _repository.HasActiveDeviceAsync(clientId, ct); + + await _uow.BeginAsync(); + try + { + var invoice = await CreatePlatformInvoiceAsync(clientId, + "Onboarding & setup fee", + $"One-time setup fee for the {plan.PackageName ?? "Standard"} plan", + plan.SetupFee, + hasDevice + ? new[] { ("Access device installation", "One-time installation fee", devicePricing.DeviceInstallationFee) } + : Array.Empty<(string, string, decimal)>(), + ct); + + await _uow.CommitAsync(); + return invoice; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task> RunMonthlyBillingForAllClientsAsync(CancellationToken ct = default) + { + var startOfMonth = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc); + var clientIds = await _repository + .GetMonthlyBillingCandidatesAsync(startOfMonth, ct); + + var results = new List(); + foreach (var clientId in clientIds) + { + // Skip clients already billed for the current period, and clients with no card on file yet + // (activation not completed) — nothing meaningful to invoice for those. + results.Add(await GenerateMonthlyInvoiceAsync(clientId, ct)); + } + + return results; + } + + // Closes the loop RunMonthlyBillingForAllClientsAsync leaves open: generates any + // invoices newly due this period, then attempts (or re-polls) a real Payment-Service + // charge for every still-outstanding platform invoice that has a card on file. + // AttemptPaymentAsync is idempotent per invoice (IdempotencyKey below), so simply + // re-calling it on a later run is a safe, correct way to both charge new invoices and + // poll ones still Processing from a prior run — no separate local "pending" bookkeeping. + public async Task RunAndChargeSubscriptionBillingAsync(CancellationToken ct = default) + { + var result = new SubscriptionChargeRunResult(); + + var generated = await RunMonthlyBillingForAllClientsAsync(ct); + result.InvoicesGenerated = generated.Count; + + var outstanding = await _repository.GetInvoicesByStatusAsync("Outstanding", newestFirst: false, ct); + + foreach (var invoice in outstanding) + { + var balance = invoice.Amount - invoice.PaidAmount; + if (balance <= 0) + continue; + + var clientId = invoice.ClientSubscription.FKClientId; + var card = await _repository.GetClientCardAsync(clientId, ct); + if (card is null || string.IsNullOrEmpty(card.CardToken)) + { + result.SkippedNoCard++; + continue; + } + + // The most recent charge attempt already recorded for this invoice, if any. + // Payment-Service ties AmountMinorUnits to the IdempotencyKey permanently (a + // mismatched amount on replay throws), so a fresh charge always uses the + // invoice's current balance, but re-polling an in-flight one must reuse the + // exact amount it was created with. + var existingTxn = (await _paymentRepository.GetPaymentsByClientInvoiceIdAsync(invoice.ClientInvoiceId)) + .Where(t => t.RefType == "platform-subscription") + .OrderByDescending(t => t.PayDate) + .FirstOrDefault(); + + if (existingTxn?.PayStatus == "Failed") + { + // Payment-Service payments can never leave Failed — left Pending for the + // existing manual pay-link fallback (SendClientPaymentLinkAsync) rather than + // retried automatically forever. + result.SkippedPreviouslyFailed++; + continue; + } + + decimal chargeAmount; + if (existingTxn?.PayStatus == "Processing") + { + if (existingTxn.PayAmount != balance) + { + // The balance moved since the in-flight attempt was created (e.g. a + // manual partial payment) — retrying would either overcharge or hit + // Payment-Service's idempotency-conflict check. Needs manual review. + result.SkippedAmountMismatch++; + continue; + } + chargeAmount = existingTxn.PayAmount; + } + else + { + chargeAmount = balance; + } + + AttemptPaymentResult attempt; + try + { + attempt = await _paymentGatewayClient.AttemptPaymentAsync(clientId, new AttemptPaymentRequest + { + IdempotencyKey = $"platform-invoice-{invoice.ClientInvoiceId}", + GatewayName = GatewayName, + CardToken = card.CardToken, + AmountMinorUnits = chargeAmount * 100m, + Currency = "LKR", + Description = $"HexaFitness platform invoice {invoice.ClientInvoiceId}" + }, ct); + } + catch (Exception ex) + { + // Non-fatal for the rest of the run: this invoice is retried (idempotently) + // next run, but the caller needs to see the run wasn't fully clean. + Console.WriteLine($"Subscription charge attempt failed for invoice {invoice.ClientInvoiceId}: {ex.Message}"); + result.Errors++; + continue; + } + + result.ChargesAttempted++; + + await _uow.BeginAsync(); + try + { + if (existingTxn is null || existingTxn.PaymentReferenceId != attempt.PaymentId.ToString()) + { + await _paymentRepository.CreatePaymentAttemptAsync(new PaymentTransaction + { + PayDate = attempt.CreatedAtUtc, + PayStatus = attempt.Status, + PayAmount = chargeAmount, + FK_ClientInvoiceId = invoice.ClientInvoiceId, + FK_ClientId = clientId, + BeneficiaryType = BeneficiaryType.Client, + RefType = "platform-subscription", + PaymentReferenceId = attempt.PaymentId.ToString(), + IsActive = true, + IsApproved = attempt.Status == "Completed" + }); + } + else + { + existingTxn.PayStatus = attempt.Status; + existingTxn.IsApproved = attempt.Status == "Completed"; + await _paymentRepository.UpdatePaymentAttemptAsync(existingTxn); + } + + if (attempt.Status == "Completed") + { + invoice.PaidAmount += chargeAmount; + invoice.Status = invoice.PaidAmount >= invoice.Amount ? "Paid" : "Partial"; + result.Paid++; + } + else if (attempt.Status == "Failed") + { + result.Failed++; + } + else + { + result.StillProcessing++; + } + + await _uow.CommitAsync(); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + return result; + } + + public async Task> GetPendingInvoicesAsync(CancellationToken ct = default) + { + var pending = await _repository + .GetInvoicesByStatusAsync("Outstanding", newestFirst: false, ct); + + return pending.Select(ToPendingDto).ToList(); + } + + public async Task> GetCompletedInvoicesAsync(CancellationToken ct = default) + { + var completed = await _repository + .GetInvoicesByStatusAsync("Paid", newestFirst: true, ct); + + return completed.Select(ToPendingDto).ToList(); + } + + public async Task RecordClientInvoicePaymentAsync(Guid clientInvoiceId, decimal amount, CancellationToken ct = default) + { + if (amount <= 0) + throw new BadRequestException("Payment amount must be greater than zero."); + + await _uow.BeginAsync(); + try + { + var invoice = await _repository.GetInvoiceWithClientAsync( + clientInvoiceId, includeBusinesses: true, ct) + ?? throw new NotFoundException("Platform invoice not found"); + + var balance = invoice.Amount - invoice.PaidAmount; + if (balance <= 0) + throw new BadRequestException("This invoice is already fully paid."); + + invoice.PaidAmount += Math.Min(amount, balance); + invoice.Status = invoice.PaidAmount >= invoice.Amount ? "Paid" : "Partial"; + + await _uow.CommitAsync(); + + var client = invoice.ClientSubscription.Client; + if (client != null) + await NotifyPaymentActivityAsync(client, invoice, sendPaymentLink: invoice.Status != "Paid"); + + return ToPendingDto(invoice); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task SendClientPaymentLinkAsync(Guid clientInvoiceId, CancellationToken ct = default) + { + var invoice = await _repository.GetInvoiceWithClientAsync( + clientInvoiceId, includeBusinesses: false, ct) + ?? throw new NotFoundException("Platform invoice not found"); + + var client = invoice.ClientSubscription.Client + ?? throw new NotFoundException("Client not found for this invoice"); + + if (invoice.Amount - invoice.PaidAmount <= 0) + throw new BadRequestException("This invoice is already fully paid."); + + await NotifyPaymentActivityAsync(client, invoice, sendPaymentLink: true); + return new { sent = true }; + } + + private async Task NotifyPaymentActivityAsync(Client client, Models.ClientInvoice invoice, bool sendPaymentLink) + { + var balance = invoice.Amount - invoice.PaidAmount; + var message = invoice.Status == "Paid" + ? $"HexaFitness: payment of Rs. {invoice.PaidAmount:N2} received. Thank you!" + : $"HexaFitness: Rs. {balance:N2} due on your account."; + + if (sendPaymentLink && balance > 0) + { + var token = _paymentTokenHelper.Generate(invoice.ClientInvoiceId); + var baseUrl = (_config["App:FrontendBaseUrl"] ?? string.Empty).TrimEnd('/'); + var link = $"{baseUrl}/pay-platform/{token}"; + message += $" Pay online: {link}"; + } + + if (!string.IsNullOrWhiteSpace(client.ClientPhone)) + { + try + { + await _thirdPartyService.SendSmsAsync(client.ClientPhone!, message: message); + } + catch (Exception ex) + { + Console.WriteLine($"Platform invoice SMS failed (non-critical): {ex.Message}"); + } + } + } + + private async Task<(int DeviceCount, decimal Fee)> ComputeDeviceFeeAsync(Guid clientId, CancellationToken ct) + { + var devices = await _accessDeviceRepo.GetDevicesByClientAsync(clientId); + var activeDeviceCount = devices.Count(d => d.IsActive); + + var deviceBilling = await _repository + .GetLatestActiveDeviceBillingAsync(clientId, ct); + + if (deviceBilling != null) + { + // Rental contributes a recurring monthly fee; a full purchase is one-time + // (already billed at assignment time) and adds nothing to the recurring fee. + var fee = deviceBilling.Mode == "Rental" ? deviceBilling.AgreedAmount : 0; + return (activeDeviceCount, fee); + } + + // No per-client agreement recorded yet: fall back to the global default rate. + var devicePricing = await GetDeviceConfigOrDefaultAsync(ct); + return (activeDeviceCount, activeDeviceCount * devicePricing.DeviceRentalFeePerMonth); + } + + private static decimal ComputeGatewayFee(Client client, ClientPackage plan, decimal baseAmount) + { + var isOwnGateway = client.PaymentTrack == Models.Enums.PaymentTrack.OwnGateway; + var percent = isOwnGateway ? plan.OwnGatewayFeePercent : plan.OurGatewayFeePercent; + var fixedFee = isOwnGateway ? plan.OwnGatewayFeeFixed : plan.OurGatewayFeeFixed; + return Math.Round(baseAmount * percent / 100m, 2) + fixedFee; + } + + private async Task CreatePlatformInvoiceAsync( + Guid clientId, + string primaryItemName, + string primaryItemDescription, + decimal primaryItemAmount, + IEnumerable<(string Name, string Description, decimal Amount)> extraItems, + CancellationToken ct) + { + var client = await _repository.GetClientByIdAsync(clientId, ct) + ?? throw new NotFoundException("Client not found"); + + var subscription = await _repository + .GetActiveClientSubscriptionAsync(clientId, ct); + + if (subscription == null) + { + var defaultPlan = await _repository.GetDefaultPackageAsync(ct); + if (defaultPlan == null) + { + defaultPlan = CreateDefaultPlan(); + await _repository.AddPackageAsync(defaultPlan, ct); + } + subscription = new ClientSubscription + { + SubscriptionId = Guid.NewGuid(), + FKClientId = clientId, + FKClientPackageId = defaultPlan.ClientPackageId, + OnboardDate = DateTime.UtcNow, + IsActive = true + }; + await _repository.AddClientSubscriptionAsync(subscription, ct); + } + + var clientCard = await _repository.GetClientCardAsync(clientId, ct) + ?? throw new BadRequestException("No card on file for this gym yet — cannot generate an invoice."); + + var items = new List<(string Name, string Description, decimal Amount)> + { + (primaryItemName, primaryItemDescription, primaryItemAmount) + }; + items.AddRange(extraItems); + + var invoice = new Models.ClientInvoice + { + ClientInvoiceId = Guid.NewGuid(), + FKClientSubscriptionId = subscription.SubscriptionId, + FKUserId = client.FKUserId, + FKClientCardId = clientCard.CardId, + Amount = items.Sum(i => i.Amount), + PaidAmount = 0, + Status = "Pending", + ClientInvoiceDate = DateTime.UtcNow + }; + var invoiceItems = items + .Select(item => new ClientInvoiceItem + { + ClientInvoiceItemId = Guid.NewGuid(), + FKClientInvoiceId = invoice.ClientInvoiceId, + ItemName = item.Name, + ItemDescription = item.Description, + ItemPrice = item.Amount, + CreatedAt = DateTime.UtcNow + }) + .ToList(); + await _repository.AddInvoiceAsync(invoice, invoiceItems, ct); + + return invoice; + } + + private async Task ResolvePlanForClientAsync(Guid clientId, CancellationToken ct) + { + return await _repository.GetActivePackageForClientAsync(clientId, ct) + ?? await _repository.GetDefaultPackageAsync(ct) + ?? CreateDefaultPlan(); + } + + private static ClientPackage CreateDefaultPlan() + { + return new ClientPackage + { + ClientPackageId = Guid.NewGuid(), + PackageName = "Standard", + PackageDescription = "Default platform pricing plan", + PerMemberRate = 0, + MinimumMonthlyBill = 0, + MonthlyCap = null, + SetupFee = 0, + IsDefault = true, + IsActive = true + }; + } + + private async Task GetDeviceConfigOrDefaultAsync(CancellationToken ct) + { + return await _repository.GetDevicePricingConfigAsync(ct) + ?? CreateDefaultDeviceConfig(); + } + + private static DevicePricingConfig CreateDefaultDeviceConfig() => + new() { DevicePricingConfigId = Guid.NewGuid() }; + + private static PricingPlanDto ToDto(ClientPackage plan) => new() + { + ClientPackageId = plan.ClientPackageId, + PackageName = plan.PackageName, + PackageDescription = plan.PackageDescription, + PerMemberRate = plan.PerMemberRate, + MinimumMonthlyBill = plan.MinimumMonthlyBill, + MonthlyCap = plan.MonthlyCap, + SetupFee = plan.SetupFee, + IncludedMemberCount = plan.IncludedMemberCount, + MaxBillableMemberCount = plan.MaxBillableMemberCount, + OwnGatewayFeePercent = plan.OwnGatewayFeePercent, + OwnGatewayFeeFixed = plan.OwnGatewayFeeFixed, + OurGatewayFeePercent = plan.OurGatewayFeePercent, + OurGatewayFeeFixed = plan.OurGatewayFeeFixed, + PayoutDayOfMonth = plan.PayoutDayOfMonth, + IsDefault = plan.IsDefault, + IsActive = plan.IsActive + }; + + private static PendingPlatformInvoiceDto ToPendingDto(Models.ClientInvoice invoice) + { + var now = DateTime.UtcNow; + return new PendingPlatformInvoiceDto + { + ClientInvoiceId = invoice.ClientInvoiceId, + ClientId = invoice.ClientSubscription.FKClientId, + ClientName = invoice.ClientSubscription.Client?.ClientName, + City = invoice.ClientSubscription.Client?.ClientBusinesses?.FirstOrDefault()?.City, + Amount = invoice.Amount, + PaidAmount = invoice.PaidAmount, + Status = invoice.Status, + ClientInvoiceDate = invoice.ClientInvoiceDate, + DaysOverdue = Math.Max(0, (int)(now - invoice.ClientInvoiceDate).TotalDays) + }; + } + } +} diff --git a/Services/ProfitLossService.cs b/Services/ProfitLossService.cs new file mode 100644 index 0000000..81b6bde --- /dev/null +++ b/Services/ProfitLossService.cs @@ -0,0 +1,161 @@ +using Fitness_core_BE.DTOs.Ledger; +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Services +{ + public class ProfitLossService : IProfitLossService + { + private readonly IProfitLossRepository _repository; + private readonly UserRoleHelper _userRoleHelper; + private readonly IUnitOfWork _uow; + + public ProfitLossService( + IProfitLossRepository repository, + UserRoleHelper userRoleHelper, + IUnitOfWork uow) + { + _repository = repository; + _userRoleHelper = userRoleHelper; + _uow = uow; + } + + public async Task GetProfitLossAsync(DateTime from, DateTime to, HttpContext httpContext) + { + var clientId = await GetClientIdAsync(httpContext); + var (fromUtc, toUtc) = NormalizeRange(from, to); + + var entries = await _repository.GetDerivedEntriesAsync( + clientId, fromUtc, toUtc); + + // Manual entries. + var manualEntries = await GetManualEntriesRawAsync(clientId, fromUtc, toUtc); + entries.AddRange(manualEntries.Select(ToDto)); + + var totalIncome = entries.Where(e => e.EntryType == "Income").Sum(e => e.Amount); + var totalExpense = entries.Where(e => e.EntryType == "Expense").Sum(e => e.Amount); + + return new ProfitLossDto + { + From = fromUtc, + To = toUtc, + TotalIncome = totalIncome, + TotalExpense = totalExpense, + NetProfit = totalIncome - totalExpense, + IncomeByCategory = entries.Where(e => e.EntryType == "Income") + .GroupBy(e => e.Category) + .Select(g => new CategoryTotalDto { Category = g.Key, Amount = g.Sum(e => e.Amount) }) + .OrderByDescending(c => c.Amount) + .ToList(), + ExpenseByCategory = entries.Where(e => e.EntryType == "Expense") + .GroupBy(e => e.Category) + .Select(g => new CategoryTotalDto { Category = g.Key, Amount = g.Sum(e => e.Amount) }) + .OrderByDescending(c => c.Amount) + .ToList(), + Entries = entries.OrderByDescending(e => e.EntryDate).ToList() + }; + } + + public async Task> GetManualEntriesAsync(DateTime from, DateTime to, HttpContext httpContext) + { + var clientId = await GetClientIdAsync(httpContext); + var (fromUtc, toUtc) = NormalizeRange(from, to); + var entries = await GetManualEntriesRawAsync(clientId, fromUtc, toUtc); + return entries.Select(ToDto).OrderByDescending(e => e.EntryDate).ToList(); + } + + public async Task AddManualEntryAsync(AddLedgerEntryRequest request, HttpContext httpContext) + { + var clientId = await GetClientIdAsync(httpContext); + + if (request.EntryType != "Income" && request.EntryType != "Expense") + throw new ArgumentException("entryType must be 'Income' or 'Expense'."); + if (string.IsNullOrWhiteSpace(request.Category)) + throw new ArgumentException("category is required."); + if (request.Amount <= 0) + throw new ArgumentException("amount must be greater than zero."); + + await _uow.BeginAsync(); + try + { + var entry = new LedgerEntry + { + Id = Guid.NewGuid(), + FKClientId = clientId, + EntryType = request.EntryType, + Category = request.Category, + Description = request.Description, + Amount = request.Amount, + EntryDate = request.EntryDate ?? DateTime.UtcNow, + CreatedAt = DateTime.UtcNow + }; + await _repository.AddManualEntryAsync(entry); + await _uow.CommitAsync(); + + return ToDto(entry); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task DeleteManualEntryAsync(Guid id, HttpContext httpContext) + { + var clientId = await GetClientIdAsync(httpContext); + + var entry = await _repository.GetManualEntryByIdAsync(id) + ?? throw new NotFoundException("Ledger entry not found."); + + if (entry.FKClientId != clientId) + throw new ForbiddenException("This ledger entry does not belong to your gym."); + + await _uow.BeginAsync(); + try + { + _repository.RemoveManualEntry(entry); + await _uow.CommitAsync(); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + private async Task> GetManualEntriesRawAsync(Guid clientId, DateTime fromUtc, DateTime toUtc) + { + return await _repository.GetManualEntriesAsync(clientId, fromUtc, toUtc); + } + + private async Task GetClientIdAsync(HttpContext httpContext) + { + var userId = DataExtractor.GetUserIdFromContext(httpContext); + return await _userRoleHelper.GetClientId(userId); + } + + private static (DateTime From, DateTime To) NormalizeRange(DateTime from, DateTime to) + { + var fromUtc = DateTime.SpecifyKind(from.Date, DateTimeKind.Utc); + // Treat `to` as inclusive of the whole day. + var toUtc = DateTime.SpecifyKind(to.Date.AddDays(1), DateTimeKind.Utc); + return (fromUtc, toUtc); + } + + private static LedgerEntryDto ToDto(LedgerEntry entry) => new() + { + Id = entry.Id, + EntryType = entry.EntryType, + Category = entry.Category, + Description = entry.Description, + Amount = entry.Amount, + EntryDate = entry.EntryDate, + IsManual = true + }; + } +} diff --git a/Services/PushNotificationService.cs b/Services/PushNotificationService.cs new file mode 100644 index 0000000..8b6aee6 --- /dev/null +++ b/Services/PushNotificationService.cs @@ -0,0 +1,152 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Services +{ + // Sends real device pushes via Expo's push API and, independently, persists a + // CustomerNotification row so the mobile Notification tab always has a history even if the + // push itself never reached the device (permission denied, no token registered, etc.). + public class PushNotificationService : IPushNotificationService + { + private const string ExpoPushUrl = "https://exp.host/--/api/v2/push/send"; + private const int ExpoBatchSize = 100; + + private readonly HttpClient _httpClient; + private readonly ICustomerNotificationRepository _repository; + private readonly IUnitOfWork _uow; + + public PushNotificationService(IHttpClientFactory httpClientFactory, ICustomerNotificationRepository repository, IUnitOfWork uow) + { + _httpClient = httpClientFactory.CreateClient(); + _repository = repository; + _uow = uow; + } + + public async Task NotifyCustomerAsync( + Guid customerId, + string title, + string body, + string notificationType, + string? routeName = null, + object? routeParams = null) + { + var notification = BuildNotification(customerId, title, body, notificationType, routeName, routeParams); + + await _uow.BeginAsync(); + try + { + await _repository.CreateAsync(notification); + await _uow.CommitAsync(); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + + var pushToken = await _repository.GetCustomerPushTokenAsync(customerId); + if (IsExpoPushToken(pushToken)) + { + await SendExpoPushBatchAsync(new[] { pushToken! }, title, body, notificationType, routeName, routeParams); + } + } + + public async Task NotifyCustomersAsync( + IEnumerable customerIds, + string title, + string body, + string notificationType, + string? routeName = null, + object? routeParams = null) + { + var ids = customerIds.Distinct().ToList(); + if (ids.Count == 0) return; + + var notifications = ids + .Select(id => BuildNotification(id, title, body, notificationType, routeName, routeParams)) + .ToList(); + + await _uow.BeginAsync(); + try + { + await _repository.CreateManyAsync(notifications); + await _uow.CommitAsync(); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + + var tokens = await _repository.GetCustomerPushTokensAsync(ids); + var validTokens = tokens.Where(IsExpoPushToken).ToList(); + + for (var offset = 0; offset < validTokens.Count; offset += ExpoBatchSize) + { + var chunk = validTokens.Skip(offset).Take(ExpoBatchSize).ToList(); + await SendExpoPushBatchAsync(chunk, title, body, notificationType, routeName, routeParams); + } + } + + private static CustomerNotification BuildNotification( + Guid customerId, string title, string body, string notificationType, string? routeName, object? routeParams) + { + return new CustomerNotification + { + CustomerNotificationId = Guid.NewGuid(), + FKCustomerId = customerId, + Title = title, + Body = body, + NotificationType = notificationType, + RouteName = routeName, + RouteParamsJson = routeParams != null ? JsonSerializer.Serialize(routeParams) : null, + IsRead = false, + CreatedAt = DateTime.UtcNow + }; + } + + private static bool IsExpoPushToken(string? token) => + !string.IsNullOrWhiteSpace(token) && token.StartsWith("ExponentPushToken", StringComparison.OrdinalIgnoreCase); + + private async Task SendExpoPushBatchAsync( + IReadOnlyCollection tokens, string title, string body, string notificationType, string? routeName, object? routeParams) + { + if (tokens.Count == 0) return; + + var messages = tokens.Select(token => new + { + to = token, + title, + body, + sound = "default", + data = new + { + notificationType = routeName != null ? "navigate" : "normal", + routeName, + routeParams, + notificationKind = notificationType + } + }); + + try + { + var response = await _httpClient.PostAsJsonAsync(ExpoPushUrl, messages); + if (!response.IsSuccessStatusCode) + { + var responseBody = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"Expo push send failed ({(int)response.StatusCode}): {responseBody}"); + } + } + catch (Exception ex) + { + // Push delivery is best-effort — the notification is already persisted above, + // so a network hiccup here must not fail the caller's larger operation. + Console.WriteLine($"Expo push send threw: {ex.Message}"); + } + } + } +} diff --git a/Services/ReceiptService.cs b/Services/ReceiptService.cs new file mode 100644 index 0000000..944f8b3 --- /dev/null +++ b/Services/ReceiptService.cs @@ -0,0 +1,233 @@ +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using Fitness_core_BE.Models.Receipt; +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; + +namespace Fitness_core_BE.Services +{ + public class ReceiptService : IReceiptService + { + private readonly ICustomerInvoiceRepository _customerInvoiceRepository; + private readonly ICustomerSubscriptionRepository _customerSubscriptionRepository; + private readonly IClientRepository _clientRepository; + private readonly IClientInvoiceRepository _clientInvoiceRepository; + private readonly IPaymentRepository _paymentRepository; + private readonly IThirdPartyService _thirdPartyService; + + public ReceiptService( + ICustomerInvoiceRepository customerInvoiceRepository, + ICustomerSubscriptionRepository customerSubscriptionRepository, + IClientRepository clientRepository, + IClientInvoiceRepository clientInvoiceRepository, + IPaymentRepository paymentRepository, + IThirdPartyService thirdPartyService) + { + _customerInvoiceRepository = customerInvoiceRepository; + _customerSubscriptionRepository = customerSubscriptionRepository; + _clientRepository = clientRepository; + _clientInvoiceRepository = clientInvoiceRepository; + _paymentRepository = paymentRepository; + _thirdPartyService = thirdPartyService; + } + + public async Task BuildForInvoiceAsync(Guid customerInvoiceId, decimal? paymentAmount = null, Guid? requireClientId = null) + { + var invoice = await _customerInvoiceRepository.GetCustomerInvoiceById(customerInvoiceId) + ?? throw new NotFoundException("Invoice not found"); + + if (requireClientId.HasValue && invoice.FKClientId != requireClientId.Value) + throw new UnauthorizedException("This invoice does not belong to your gym."); + + var subscription = await _customerSubscriptionRepository.GetCustomerSubscriptionById(invoice.FKCustomerSubscriptionId); + string planName = subscription?.CustomerPlan?.PlanName ?? "Membership"; + Guid? customerId = subscription?.FKCustomerId; + + Customer? customer = customerId.HasValue ? await _clientRepository.GetCustomerById(customerId.Value) : null; + var client = await _clientRepository.GetClientByIdAsync(invoice.FKClientId); + string gymName = client?.ClientBusinesses?.FirstOrDefault()?.BusinessName + ?? client?.ClientName + ?? "GYM OS"; + + decimal paid = await _paymentRepository.GetApprovedPaidAmountAsync(customerInvoiceId); + + return new ReceiptModel + { + ClientId = invoice.FKClientId, + ReceiptNo = $"GYMOS-{customerInvoiceId.ToString("N")[..8].ToUpperInvariant()}", + Date = DateTime.UtcNow, + GymName = gymName, + MemberName = customer?.CusName ?? "Member", + MemberPhone = customer?.PrimaryPH, + MemberEmail = customer?.Email, + PlanName = planName, + AmountPaid = paymentAmount ?? paid, + InvoiceAmount = invoice.Amount, + Balance = invoice.Amount - paid, + Currency = "LKR" + }; + } + + public async Task BuildForClientInvoiceAsync(Guid clientInvoiceId, decimal? paymentAmount = null, Guid? requireClientId = null) + { + var invoice = await _clientInvoiceRepository.GetClientInvoiceById(clientInvoiceId) + ?? throw new NotFoundException("Invoice not found"); + + var clientId = invoice.ClientSubscription?.FKClientId ?? Guid.Empty; + if (requireClientId.HasValue && clientId != requireClientId.Value) + throw new UnauthorizedException("This invoice does not belong to your gym."); + + var client = invoice.ClientSubscription?.Client ?? await _clientRepository.GetClientByIdAsync(clientId); + string gymName = client?.ClientBusinesses?.FirstOrDefault()?.BusinessName + ?? client?.ClientName + ?? "Gym"; + + var payments = await _paymentRepository.GetPaymentsByClientInvoiceIdAsync(clientInvoiceId); + decimal paid = payments.Where(p => p.IsApproved == true).Sum(p => p.PayAmount); + + string itemSummary = invoice.Items?.FirstOrDefault()?.ItemName ?? "Platform fee"; + + return new ReceiptModel + { + ClientId = clientId, + ReceiptNo = $"HEXA-{clientInvoiceId.ToString("N")[..8].ToUpperInvariant()}", + Date = DateTime.UtcNow, + GymName = "HexaFitness", + MemberName = gymName, + MemberPhone = client?.ClientPhone, + MemberEmail = client?.ClientEmail, + PlanName = itemSummary, + AmountPaid = paymentAmount ?? paid, + InvoiceAmount = invoice.Amount, + Balance = invoice.Amount - paid, + Currency = "LKR" + }; + } + + public byte[] GeneratePdf(ReceiptModel m) + { + string money(decimal v) => $"{m.Currency} {v:N2}"; + + var document = QuestPDF.Fluent.Document.Create(container => + { + container.Page(page => + { + page.Size(PageSizes.A5); + page.Margin(28); + page.DefaultTextStyle(x => x.FontSize(11).FontColor(Colors.Grey.Darken3)); + + page.Header().Column(col => + { + col.Item().Text("GYM OS").FontSize(20).Bold().FontColor(Colors.Blue.Darken2); + col.Item().Text(m.GymName).FontSize(13).SemiBold(); + col.Item().PaddingTop(2).Text("Payment Receipt").FontSize(10).FontColor(Colors.Grey.Medium); + col.Item().PaddingTop(6).LineHorizontal(1).LineColor(Colors.Grey.Lighten2); + }); + + page.Content().PaddingVertical(12).Column(col => + { + col.Spacing(6); + + col.Item().Row(row => + { + row.RelativeItem().Text($"Receipt No: {m.ReceiptNo}"); + row.RelativeItem().AlignRight().Text($"Date: {m.Date:yyyy-MM-dd}"); + }); + + col.Item().PaddingTop(4).Text("Billed to").FontColor(Colors.Grey.Medium).FontSize(9); + col.Item().Text(m.MemberName).SemiBold(); + if (!string.IsNullOrWhiteSpace(m.MemberPhone)) + col.Item().Text(m.MemberPhone!).FontSize(10); + if (!string.IsNullOrWhiteSpace(m.MemberEmail)) + col.Item().Text(m.MemberEmail!).FontSize(10); + + col.Item().PaddingTop(10).Table(table => + { + table.ColumnsDefinition(c => + { + c.RelativeColumn(3); + c.RelativeColumn(2); + }); + + table.Cell().Element(HeaderCell).Text("Description"); + table.Cell().Element(HeaderCell).AlignRight().Text("Amount"); + + table.Cell().Element(BodyCell).Text(m.PlanName); + table.Cell().Element(BodyCell).AlignRight().Text(money(m.AmountPaid)); + + static IContainer HeaderCell(IContainer c) => + c.Background(Colors.Grey.Lighten3).PaddingVertical(5).PaddingHorizontal(8).DefaultTextStyle(t => t.SemiBold()); + static IContainer BodyCell(IContainer c) => + c.BorderBottom(1).BorderColor(Colors.Grey.Lighten2).PaddingVertical(5).PaddingHorizontal(8); + }); + + col.Item().PaddingTop(10).AlignRight().Column(totals => + { + totals.Item().Text($"Amount paid: {money(m.AmountPaid)}").SemiBold(); + totals.Item().Text($"Invoice total: {money(m.InvoiceAmount)}").FontSize(10).FontColor(Colors.Grey.Medium); + totals.Item().Text($"Balance due: {money(m.Balance)}").FontSize(10) + .FontColor(m.Balance > 0 ? Colors.Red.Medium : Colors.Green.Darken1); + }); + }); + + page.Footer().AlignCenter().Column(col => + { + col.Item().PaddingTop(6).LineHorizontal(1).LineColor(Colors.Grey.Lighten2); + col.Item().PaddingTop(6).Text("Thank you for your payment.").FontSize(10); + col.Item().Text($"{m.GymName} · powered by GYM OS").FontSize(8).FontColor(Colors.Grey.Medium); + }); + }); + }); + + return document.GeneratePdf(); + } + + public async Task SendReceiptAsync(ReceiptModel m) + { + byte[] pdf; + try + { + pdf = GeneratePdf(m); + } + catch (Exception ex) + { + Console.WriteLine($"Receipt PDF generation failed (non-critical): {ex.Message}"); + return; + } + + if (!string.IsNullOrWhiteSpace(m.MemberEmail)) + { + try + { + string html = + $"

Hi {m.MemberName},

" + + $"

Thank you for your payment of {m.Currency} {m.AmountPaid:N2} for {m.PlanName} at {m.GymName}.

" + + (m.Balance > 0 ? $"

Outstanding balance: {m.Currency} {m.Balance:N2}.

" : "") + + $"

Your receipt ({m.ReceiptNo}) is attached.

" + + $"

— {m.GymName}
powered by GYM OS

"; + await _thirdPartyService.SendEmailAsync(m.MemberEmail!, $"Receipt {m.ReceiptNo} — {m.GymName}", html, pdf, $"{m.ReceiptNo}.pdf"); + } + catch (Exception ex) + { + Console.WriteLine($"Receipt email failed (non-critical): {ex.Message}"); + } + } + + if (!string.IsNullOrWhiteSpace(m.MemberPhone)) + { + try + { + string sms = $"You paid {m.Currency} {m.AmountPaid:N2} for {m.PlanName} at {m.GymName}. Thank you! - GYM OS"; + await _thirdPartyService.SendSmsAsync(m.MemberPhone!, null, sms); + } + catch (Exception ex) + { + Console.WriteLine($"Receipt SMS failed (non-critical): {ex.Message}"); + } + } + } + } +} diff --git a/Services/StaffService.cs b/Services/StaffService.cs new file mode 100644 index 0000000..6ab4212 --- /dev/null +++ b/Services/StaffService.cs @@ -0,0 +1,419 @@ +using Fitness_core_BE.DTOs.Auth; +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.DTOs.Staff; +using System.Text.Json; + +namespace Fitness_core_BE.Services.Staff +{ + public class StaffService : IStaffService + { + private readonly IStaffRepository _staffRepository; + private readonly IUnitOfWork _uow; + private readonly UserRoleHelper _userRoleHelper; + private readonly IAuthService _authService; + private readonly IAccessDeviceService _accessDeviceService; + private readonly IGymRoleRepository _gymRoleRepository; + + public StaffService(IStaffRepository staffRepository, IUnitOfWork uow, UserRoleHelper userRoleHelper, IAuthService authService, IAccessDeviceService accessDeviceService, IGymRoleRepository gymRoleRepository) + { + _staffRepository = staffRepository; + _uow = uow; + _userRoleHelper = userRoleHelper; + _authService = authService; + _accessDeviceService = accessDeviceService; + _gymRoleRepository = gymRoleRepository; + } + + public async Task GetAllStaff(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + + bool includeInactive = data != null && + data.ContainsKey("includeInactive") && + data["includeInactive"].GetBoolean(); + + var staffList = await _staffRepository.GetAllStaff(includeInactive); + + return await MapStaffListAsync(staffList); + } + + public async Task GetStaffById(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + + if (!data!.ContainsKey("staffId")) + { + throw new KeyNotFoundException("staffId is required"); + } + + var staffId = Guid.Parse(data["staffId"].GetString()!); + var staff = await _staffRepository.GetStaffById(staffId); + + if (staff == null) + { + throw new KeyNotFoundException("Staff not found"); + } + var callerClientId = await _userRoleHelper.GetClientId( + DataExtractor.GetUserIdFromContext(httpContext)); + if (staff.FKClientId != callerClientId) + throw new ForbiddenException("This staff member does not belong to your gym."); + + return await MapStaffAsync(staff); + } + + public async Task GetStaffByClientId(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + + if (!data!.ContainsKey("clientId")) + { + throw new KeyNotFoundException("clientId is required"); + } + + var clientId = Guid.Parse(data["clientId"].GetString()!); + var callerClientId = await _userRoleHelper.GetClientId( + DataExtractor.GetUserIdFromContext(httpContext)); + if (clientId != callerClientId) + throw new ForbiddenException("You cannot view another gym's staff."); + + bool includeInactive = data.ContainsKey("includeInactive") && + data["includeInactive"].GetBoolean(); + + var staffList = await _staffRepository.GetStaffByClientId(clientId, includeInactive); + + return await MapStaffListAsync(staffList); + } + + public async Task CreateStaff(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + if (!data!.ContainsKey("userName")) + { + throw new KeyNotFoundException("userName is required"); + } + + if (!data.ContainsKey("email")) + { + throw new KeyNotFoundException("email is required"); + } + + if (!data.ContainsKey("phoneNumber")) + { + throw new KeyNotFoundException("phoneNumber is required"); + } + + var user = new User + { + UserName = data["userName"].GetString()!, + Email = data["email"].GetString()!, + PhoneNumber = data["phoneNumber"].GetString()! + }; + + // Optional -- must belong to this same client, same check AssignStaffRole + // already does for the post-creation assignment path. Left unset, the new + // staff member has no dashboard access until a role is assigned later. + Guid? gymRoleId = null; + if (data.ContainsKey("gymRoleId") && data["gymRoleId"].ValueKind != JsonValueKind.Null + && Guid.TryParse(data["gymRoleId"].GetString(), out var parsedRoleId)) + { + var role = await _gymRoleRepository.GetRoleById(parsedRoleId); + if (role == null || role.FKClientId != clientId) + throw new BadRequestException("That role doesn't belong to your gym."); + gymRoleId = parsedRoleId; + } + + var staff = new Models.Staff + { + FKUserId = userId, + FKClientId = clientId, + StaffPosition = data.ContainsKey("staffPosition") ? data["staffPosition"].GetString() : null, + Department = data.ContainsKey("department") ? data["department"].GetString() : null, + FKGymRoleId = gymRoleId, + }; + + var result = await _staffRepository.CreateStaff(staff, user); + + await _uow.SaveChangesAsync(); + await _accessDeviceService.EnsureAccessProfileAsync( + clientId, user.UserId, "Staff", user.UserName ?? string.Empty); + + AuthResponse authResponse; + try + { + var authPayload = DeserializePayload.Deserialize(payload); + + Guid roleId = Guid.Parse("08de6a11-9e9f-4401-8a10-6854567b41ed"); + Guid userTypeId = Guid.Parse("00000000-0000-0000-0000-000000000003"); + + authPayload["userId"] = JsonSerializer.SerializeToElement(user.UserId); + authPayload["roleId"] = JsonSerializer.SerializeToElement(roleId); + authPayload["userTypeId"] = JsonSerializer.SerializeToElement(userTypeId); + // The client portal's request uses "phoneNumber", but Core-Hex-Auth's + // RegisterUser only reads "mobileNumber" — without this the auth User row + // was silently created with a null phone number. + authPayload["mobileNumber"] = JsonSerializer.SerializeToElement(user.PhoneNumber); + + authResponse = await _authService.RegisterUser(authPayload, httpContext); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Auth registration failed: {ex.Message}"); + } + + if (authResponse.StatusCode != 200 || !authResponse.Success) + { + throw new InvalidOperationException($"Auth registration failed: {authResponse.Message}"); + } + + await _uow.CommitAsync(); + + // Newly created staff have no password of their own yet — email them a + // "set your password" link (reuses the auth service's reset-link flow). + // Non-critical: a failure here must not roll back the created staff member. + try + { + await _authService.ForgotPassword( + new { identifier = user.Email, useResetLink = true, welcome = true }, + httpContext); + } + catch (Exception ex) + { + Console.WriteLine($"Staff set-password email failed (non-critical): {ex.Message}"); + } + + return new + { + staff = await MapStaffAsync(result, user), + auth = authResponse + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task UpdateStaff(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + if (!data!.ContainsKey("staffId")) + { + throw new KeyNotFoundException("staffId is required"); + } + + var staffId = Guid.Parse(data["staffId"].GetString()!); + + // Get existing staff first + var existingStaff = await _staffRepository.GetStaffById(staffId); + if (existingStaff == null) + { + throw new KeyNotFoundException("Staff not found"); + } + var callerClientId = await _userRoleHelper.GetClientId( + DataExtractor.GetUserIdFromContext(httpContext)); + if (existingStaff.FKClientId != callerClientId) + throw new ForbiddenException("This staff member does not belong to your gym."); + + // Create update object with only provided fields + var updateStaff = new Models.Staff + { + StaffId = staffId, + StaffPosition = data.ContainsKey("staffPosition") ? data["staffPosition"].GetString() : null, + Department = data.ContainsKey("department") ? data["department"].GetString() : null, + IsActive = data.ContainsKey("isActive") ? data["isActive"].GetBoolean() : existingStaff.IsActive, + User = new User + { + UserName = data.ContainsKey("userName") ? data["userName"].GetString() : null, + Email = data.ContainsKey("email") ? data["email"].GetString() : null, + PhoneNumber = data.ContainsKey("phoneNumber") ? data["phoneNumber"].GetString() : null + } + }; + + var result = await _staffRepository.UpdateStaff(updateStaff); + await _uow.CommitAsync(); + + return await MapStaffAsync(result); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task DeleteStaff(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + if (!data!.ContainsKey("staffId")) + { + throw new KeyNotFoundException("staffId is required"); + } + + var staffId = Guid.Parse(data["staffId"].GetString()!); + await EnsureStaffBelongsToCallerAsync(staffId, httpContext); + + // Check if hard delete is requested (default is soft delete) + bool softDelete = !data.ContainsKey("hardDelete") || !data["hardDelete"].GetBoolean(); + + var result = await _staffRepository.DeleteStaff(staffId, softDelete); + + if (!result) + { + throw new KeyNotFoundException("Staff not found"); + } + + await _uow.CommitAsync(); + + return new + { + success = true, + message = softDelete + ? "Staff deactivated successfully" + : "Staff deleted successfully", + softDelete = softDelete + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task GetPayRate(Guid staffId, HttpContext httpContext) + { + await EnsureStaffBelongsToCallerAsync(staffId, httpContext); + var rate = await _staffRepository.GetActivePayRateAsync(staffId); + return rate == null + ? null! + : new StaffPayRateDto { Id = rate.Id, RateType = rate.RateType, Amount = rate.Amount, EffectiveFrom = rate.EffectiveFrom }; + } + + public async Task UpsertPayRate(Guid staffId, string rateType, decimal amount, HttpContext httpContext) + { + await EnsureStaffBelongsToCallerAsync(staffId, httpContext); + + if (rateType != "Monthly" && rateType != "Hourly") + throw new BadRequestException("rateType must be 'Monthly' or 'Hourly'."); + if (amount <= 0) + throw new BadRequestException("amount must be greater than zero."); + + var currentRate = await _staffRepository.GetActivePayRateAsync(staffId); + if (currentRate != null) + currentRate.IsActive = false; + + var rate = await _staffRepository.AddPayRateAsync(new Models.StaffPayRate + { + Id = Guid.NewGuid(), + FKStaffId = staffId, + RateType = rateType, + Amount = amount, + EffectiveFrom = DateTime.UtcNow, + IsActive = true + }); + await _uow.SaveChangesAsync(); + return new StaffPayRateDto { Id = rate.Id, RateType = rate.RateType, Amount = rate.Amount, EffectiveFrom = rate.EffectiveFrom }; + } + + public async Task GetPayments(Guid staffId, HttpContext httpContext) + { + await EnsureStaffBelongsToCallerAsync(staffId, httpContext); + var payments = await _staffRepository.GetPaymentsAsync(staffId); + return payments.Select(p => new StaffPaymentDto { Id = p.Id, Amount = p.Amount, Note = p.Note, PaidAt = p.PaidAt }).ToList(); + } + + public async Task RecordPayment(Guid staffId, decimal amount, string? note, HttpContext httpContext) + { + await EnsureStaffBelongsToCallerAsync(staffId, httpContext); + + if (amount <= 0) + throw new BadRequestException("amount must be greater than zero."); + + var payment = new Models.StaffPayment + { + Id = Guid.NewGuid(), + FKStaffId = staffId, + Amount = amount, + Note = note, + PaidAt = DateTime.UtcNow, + FKPaidByUserId = DataExtractor.GetUserIdFromContext(httpContext) + }; + + await _staffRepository.AddPaymentAsync(payment); + await _uow.SaveChangesAsync(); + return new StaffPaymentDto { Id = payment.Id, Amount = payment.Amount, Note = payment.Note, PaidAt = payment.PaidAt }; + } + + private async Task EnsureStaffBelongsToCallerAsync(Guid staffId, HttpContext httpContext) + { + var userId = DataExtractor.GetUserIdFromContext(httpContext); + var clientId = await _userRoleHelper.GetClientId(userId); + + var staff = await _staffRepository.GetStaffById(staffId) + ?? throw new KeyNotFoundException("Staff not found"); + + if (staff.FKClientId != clientId) + throw new ForbiddenException("This staff member does not belong to your gym."); + } + + private async Task MapStaffAsync(Models.Staff staff, User? userOverride = null) + { + var user = userOverride ?? staff.User; + var access = await _accessDeviceService.GetAccessStatusAsync( + staff.FKClientId, staff.FKUserId); + return new StaffDto + { + StaffId = staff.StaffId, + UserId = staff.FKUserId, + UserName = user?.UserName, + Email = user?.Email, + PhoneNumber = user?.PhoneNumber, + ClientId = staff.FKClientId, + ClientName = staff.Client?.ClientName, + GymRoleId = staff.FKGymRoleId, + GymRoleName = staff.GymRole?.Name, + StaffPosition = staff.StaffPosition, + Department = staff.Department, + HireDate = staff.HireDate, + IsActive = staff.IsActive, + CreatedAt = staff.CreatedAt, + AccessProfileId = access?.AccessProfileId, + AccessNumber = access?.AccessNumber, + AccessProvisionStatus = access?.ProvisionStatus ?? "NotConfigured", + ActualAccessState = access?.ActualAccessState ?? "Unknown", + DesiredAccessState = access?.DesiredAccessState ?? "Enabled", + FingerprintCount = access?.FingerprintCount ?? 0, + AccessLastError = access?.LastError + }; + } + + private async Task> MapStaffListAsync(IEnumerable staff) + { + var result = new List(); + foreach (var member in staff) + result.Add(await MapStaffAsync(member)); + return result; + } + } +} diff --git a/Services/SubscriptionRenewalApprovalService.cs b/Services/SubscriptionRenewalApprovalService.cs new file mode 100644 index 0000000..32df15e --- /dev/null +++ b/Services/SubscriptionRenewalApprovalService.cs @@ -0,0 +1,256 @@ +using Fitness_core_BE.Domain; +using Fitness_core_BE.Exceptions; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Services +{ + // Staff-facing approval workflow for the unapproved (IsActive=false) invoices the daily + // SubscriptionRenewalJob sweep creates -- approve/change-plan/reject are the only things + // that ever advance CustomerSubscription.RenewalDate or replace a subscription's plan; the + // daily sweep itself only ever creates unapproved invoices. + public class SubscriptionRenewalApprovalService : ISubscriptionRenewalApprovalService + { + private readonly ICustomerInvoiceRepository _invoiceRepository; + private readonly ICustomerSubscriptionRepository _subscriptionRepository; + private readonly IClientRepository _clientRepository; + private readonly IUnitOfWork _uow; + private readonly UserRoleHelper _userRoleHelper; + private readonly ILogger _logger; + + public SubscriptionRenewalApprovalService( + ICustomerInvoiceRepository invoiceRepository, + ICustomerSubscriptionRepository subscriptionRepository, + IClientRepository clientRepository, + IUnitOfWork uow, + UserRoleHelper userRoleHelper, + ILogger logger) + { + _invoiceRepository = invoiceRepository; + _subscriptionRepository = subscriptionRepository; + _clientRepository = clientRepository; + _uow = uow; + _userRoleHelper = userRoleHelper; + _logger = logger; + } + + public async Task GetUnapprovedInvoicesAsync(HttpContext httpContext) + { + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + var invoices = await _invoiceRepository.GetUnapprovedInvoicesByClientIdAsync(clientId); + + // How much of `amount` is this cycle's own charge vs. debt carried over from a prior + // Pending/Partial invoice -- the approvals list shows the total already, but staff + // reviewing a renewal need to see the breakdown before approving it. + var carriedForward = await _invoiceRepository.GetCarriedForwardAmountsAsync( + invoices.Select(i => i.CustomerInvoiceId).ToList()); + + return invoices.Select(i => + { + carriedForward.TryGetValue(i.CustomerInvoiceId, out var carriedForwardAmount); + return new + { + customerInvoiceId = i.CustomerInvoiceId, + fkCustomerSubscriptionId = i.FKCustomerSubscriptionId, + amount = i.Amount, + carriedForwardAmount, + status = i.Status, + customerInvoiceDate = i.CustomerInvoiceDate, + customerName = i.CustomerSubscription?.TenantCustomerLink?.DisplayName, + planName = i.CustomerSubscription?.CustomerPlan?.PlanName, + planPrice = i.CustomerSubscription?.CustomerPlan?.PlanPrice, + }; + }).ToList(); + } + + public async Task ApproveAsync(Guid customerInvoiceId, HttpContext httpContext) + { + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + + await _uow.BeginAsync(); + try + { + var invoice = await LoadUnapprovedInvoiceForClientAsync(customerInvoiceId, clientId); + var subscription = invoice.CustomerSubscription; + var plan = subscription.CustomerPlan + ?? throw new BadRequestException("This subscription's plan could not be found."); + + var now = DateTime.UtcNow; + + // Installments are keyed by subscription, not by invoice/cycle -- the prior + // cycle's rows must be removed before this cycle's schedule is added (see + // DeactivatePriorInstallmentsAsync for why this is a hard delete). + await _subscriptionRepository.DeactivatePriorInstallmentsAsync(subscription.SubscriptionId); + var installments = CustomerSubscriptionFactory.BuildInstallmentSchedule( + subscription, plan, now, anchorDate: now); + await _subscriptionRepository.AddInstallmentsAsync(installments); + + invoice.IsActive = true; + + // Advance from the OLD RenewalDate, not from "now" -- a late approval shouldn't + // shift the cadence of future cycles. + var newRenewalDate = (subscription.RenewalDate ?? now).AddDays(plan.DurationInDays); + await _subscriptionRepository.AdvanceRenewalDateAsync(subscription.SubscriptionId, newRenewalDate); + + // Session-count cycle reset (SessionsPerCycle plans only -- unlimited-access + // plans have nothing to reset). Whatever was left unused this ending cycle either + // rolls into the new one (GracePeriod, until RolloverGraceDays elapses) or is + // forfeited (NoRollover) -- see the fields' own comments on CustomerPlan/ + // CustomerSubscription for the full reasoning. + if (plan.SessionsPerCycle.HasValue) + { + var allowanceThisCycle = plan.SessionsPerCycle.Value + subscription.SessionsCarriedOver; + var unusedThisCycle = Math.Max(0, allowanceThisCycle - subscription.SessionsUsedThisCycle); + + if (plan.SessionRolloverPolicy == "GracePeriod" && unusedThisCycle > 0) + { + subscription.SessionsCarriedOver = unusedThisCycle; + subscription.SessionsCarriedOverExpiresAt = now.AddDays(plan.RolloverGraceDays); + } + else + { + subscription.SessionsCarriedOver = 0; + subscription.SessionsCarriedOverExpiresAt = null; + } + + subscription.SessionsUsedThisCycle = 0; + } + + await _uow.SaveChangesAsync(); + await _uow.CommitAsync(); + + _logger.LogInformation( + "SubscriptionRenewalApproval: invoice {InvoiceId} approved for subscription {SubscriptionId}; RenewalDate advanced to {RenewalDate:u}.", + customerInvoiceId, subscription.SubscriptionId, newRenewalDate); + + return new + { + success = true, + message = "Renewal approved", + customerInvoiceId, + subscriptionId = subscription.SubscriptionId, + renewalDate = newRenewalDate + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task ChangePlanAsync(Guid customerInvoiceId, Guid newCustomerPlanId, HttpContext httpContext) + { + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + + await _uow.BeginAsync(); + try + { + var invoice = await LoadUnapprovedInvoiceForClientAsync(customerInvoiceId, clientId); + var oldSubscription = invoice.CustomerSubscription; + + var newPlan = await _clientRepository.GetCustomerPlanEntityForClientAsync(newCustomerPlanId, clientId) + ?? throw new NotFoundException("Membership plan not found."); + if (newPlan.PlanScope != "Individual") + throw new BadRequestException("Change Membership only supports Individual-scope plans."); + + var oldMembership = await _clientRepository.GetActiveMembershipForCustomerAsync(oldSubscription.FKCustomerId); + + // Sibling namespace Fitness_core_BE.Services.CustomerSubscription + // (CustomerSubscriptionService.cs) outranks a bare `CustomerSubscription` here. + var newSubscription = new Fitness_core_BE.Models.CustomerSubscription + { + FKCustomerPlanId = newCustomerPlanId, + FKCustomerId = oldSubscription.FKCustomerId, + OnboardDate = DateTime.UtcNow, + IsActive = true + }; + var initialSubItem = new CustomerSubItem + { + SubItemName = "Membership change (renewal approval)", + IsActive = true + }; + var aggregate = CustomerSubscriptionFactory.Create(newSubscription, newPlan, initialSubItem); + + await _clientRepository.ReplaceCustomerSubscriptionAsync(oldMembership, aggregate); + // RepointAndRecalculateInvoiceAsync re-queries the new subscription by id, so it + // must be persisted first. + await _uow.SaveChangesAsync(); + + await _invoiceRepository.RepointAndRecalculateInvoiceAsync(customerInvoiceId, newSubscription.SubscriptionId); + await _uow.SaveChangesAsync(); + await _uow.CommitAsync(); + + _logger.LogInformation( + "SubscriptionRenewalApproval: invoice {InvoiceId} approved via plan change -- subscription {OldSubscriptionId} replaced by {NewSubscriptionId} on plan {NewPlanId}.", + customerInvoiceId, oldSubscription.SubscriptionId, newSubscription.SubscriptionId, newCustomerPlanId); + + return new + { + success = true, + message = "Plan changed and renewal approved", + customerInvoiceId, + subscriptionId = newSubscription.SubscriptionId + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task RejectAsync(Guid customerInvoiceId, HttpContext httpContext) + { + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + + await _uow.BeginAsync(); + try + { + var invoice = await LoadUnapprovedInvoiceForClientAsync(customerInvoiceId, clientId); + + // Already IsActive=false (that's what put it in the approval queue) -- Status + // also needs a terminal value, otherwise it still reads as an outstanding invoice + // everywhere that isn't IsActive-filtered (e.g. GetCustomerInvoicesByClientId). + invoice.Status = "Cancelled"; + invoice.IsActive = false; + + await _clientRepository.DeactivateSubscriptionAsync(invoice.FKCustomerSubscriptionId); + + await _uow.SaveChangesAsync(); + await _uow.CommitAsync(); + + _logger.LogInformation( + "SubscriptionRenewalApproval: invoice {InvoiceId} rejected; subscription {SubscriptionId} deactivated.", + customerInvoiceId, invoice.FKCustomerSubscriptionId); + + return new { success = true, message = "Renewal rejected", customerInvoiceId }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + // Sibling namespace Fitness_core_BE.Services.CustomerInvoice (CustomerInvoiceService.cs) + // outranks a bare `CustomerInvoice` here, so the model type needs its full name. + private async Task LoadUnapprovedInvoiceForClientAsync(Guid customerInvoiceId, Guid clientId) + { + var invoice = await _invoiceRepository.GetCustomerInvoiceById(customerInvoiceId) + ?? throw new NotFoundException("Invoice not found."); + + if (invoice.FKClientId != clientId) + throw new ForbiddenException("This invoice does not belong to your gym."); + + if (invoice.IsActive) + throw new BadRequestException("This invoice has already been approved or is not awaiting approval."); + + return invoice; + } + } +} diff --git a/Services/SubscriptionRenewalJob.cs b/Services/SubscriptionRenewalJob.cs new file mode 100644 index 0000000..f7869a8 --- /dev/null +++ b/Services/SubscriptionRenewalJob.cs @@ -0,0 +1,154 @@ +using Fitness_core_BE.Infra.Tenancy; + +namespace Fitness_core_BE.Services +{ + /// + /// Daily sweep (01:00 Sri Lanka time) that checks every provisioned tenant schema for + /// CustomerSubscriptions due for renewal (RenewalDate on or before today) and creates an + /// unapproved (IsActive=false) renewal invoice for each -- see SubscriptionRenewalRunner for + /// the actual per-subscription algorithm (Paid vs. Pending/Partial carry-forward). Nothing on + /// the subscription itself changes here; approving/changing-plan/rejecting the invoice + /// (SubscriptionRenewalApprovalService) is what advances RenewalDate or replaces the plan. + /// Own, independent loop from OverdueLockJob's 6-hour cadence -- deliberately not merged with it. + /// + /// Same as OverdueLockJob, this is a background service with no HttpContext, so it can't + /// rely on TenantConnectionResolver's per-request tenant resolution -- it loops every + /// provisioned tenant schema directly via TenantSchemaIterator instead. + /// + public class SubscriptionRenewalJob : BackgroundService + { + private const string JobName = "SubscriptionRenewalJob"; + + // 01:00 Sri Lanka local time (UTC+5:30, no DST observed since 2006) -- the gyms this + // runs for are all Sri Lanka-based, so the trigger point is anchored to local time + // rather than UTC. The actual RenewalDate/candidate comparisons inside + // SubscriptionRenewalRunner still use DateTime.UtcNow, unaffected by this -- only the + // wake-up point shifts. + private static readonly TimeSpan RunAtLocal = TimeSpan.FromHours(1); + private static readonly TimeZoneInfo SriLankaTimeZone = ResolveSriLankaTimeZone(); + + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public SubscriptionRenewalJob(IServiceScopeFactory scopeFactory, ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + var delay = ComputeDelayUntilNextRun(DateTime.UtcNow); + _logger.LogInformation( + "SubscriptionRenewalJob: next run scheduled at {NextRunUtc:u} UTC (in {Delay}).", + DateTime.UtcNow.Add(delay), delay); + + try + { + await Task.Delay(delay, stoppingToken); + } + catch (TaskCanceledException) + { + // Shutting down. + break; + } + + try + { + _logger.LogInformation("SubscriptionRenewalJob: daily run starting."); + await RunOnceAsync(stoppingToken); + _logger.LogInformation("SubscriptionRenewalJob: daily run finished."); + } + catch (Exception ex) + { + _logger.LogError(ex, "SubscriptionRenewalJob run failed."); + } + } + } + + private static TimeZoneInfo ResolveSriLankaTimeZone() + { + try + { + return TimeZoneInfo.FindSystemTimeZoneById("Sri Lanka Standard Time"); // Windows ID + } + catch (TimeZoneNotFoundException) + { + try + { + return TimeZoneInfo.FindSystemTimeZoneById("Asia/Colombo"); // IANA ID (Linux/macOS) + } + catch (TimeZoneNotFoundException) + { + // Neither ID resolved (e.g. no tzdata on the host) -- Sri Lanka has kept a + // fixed UTC+5:30 offset with no DST since 2006, so a custom fixed-offset + // zone is a safe fallback rather than failing the whole job. + return TimeZoneInfo.CreateCustomTimeZone( + "Sri Lanka (fixed +05:30)", TimeSpan.FromMinutes(330), "Sri Lanka Time", "Sri Lanka Time"); + } + } + } + + // Delay-then-run, not run-then-delay: a fixed daily time trigger shouldn't fire an extra + // run just because the app happened to restart. Internal + static so it's unit-testable + // without spinning up the whole hosted service. + internal static TimeSpan ComputeDelayUntilNextRun(DateTime nowUtc) + { + var nowLocal = TimeZoneInfo.ConvertTimeFromUtc(nowUtc, SriLankaTimeZone); + var todayRunLocal = nowLocal.Date + RunAtLocal; + var nextRunLocal = nowLocal < todayRunLocal ? todayRunLocal : todayRunLocal.AddDays(1); + var nextRunUtc = TimeZoneInfo.ConvertTimeToUtc( + DateTime.SpecifyKind(nextRunLocal, DateTimeKind.Unspecified), SriLankaTimeZone); + return nextRunUtc - nowUtc; + } + + private async Task RunOnceAsync(CancellationToken ct) + { + using var scope = _scopeFactory.CreateScope(); + var iterator = scope.ServiceProvider.GetRequiredService(); + var runner = scope.ServiceProvider.GetRequiredService(); + var history = scope.ServiceProvider.GetRequiredService(); + + var now = DateTime.UtcNow; + + await foreach (var (clientId, schemaName) in iterator.GetProvisionedRegistrationsAsync(ct)) + { + // Declared outside the try so the catch block can still use schemaCtx.Central to + // record the failure -- central is one fixed database regardless of tenant, so + // it's safe to reuse (or lazily open) here even though the schema-specific work failed. + TenantSchemaContext? schemaCtx = null; + try + { + schemaCtx = iterator.OpenSchema(clientId, schemaName); + var result = await runner.RunForSchemaAsync(schemaCtx, now, ct); + + await history.RecordAsync(schemaCtx.Central, new JobRunRecord( + JobName, clientId, now, + result.Failed > 0 ? "PartialFailure" : "Success", + result.Renewed, + result.Failed > 0 ? string.Join("; ", result.Notes) : null), ct); + + if (result.Renewed > 0 || result.Failed > 0) + { + _logger.LogInformation( + "SubscriptionRenewalJob: client {ClientId} ({Schema}) -- {Candidates} candidate(s), {Renewed} renewed, {Failed} failed.", + clientId, schemaName, result.Candidates, result.Renewed, result.Failed); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "SubscriptionRenewalJob failed for client {ClientId} schema {Schema}.", clientId, schemaName); + schemaCtx ??= iterator.OpenSchema(clientId, schemaName); + await history.RecordAsync(schemaCtx.Central, new JobRunRecord(JobName, clientId, now, "Failed", 0, ex.Message), ct); + } + finally + { + if (schemaCtx != null) + await schemaCtx.DisposeAsync(); + } + } + } + } +} diff --git a/Services/SubscriptionRenewalRunner.cs b/Services/SubscriptionRenewalRunner.cs new file mode 100644 index 0000000..12b0fd9 --- /dev/null +++ b/Services/SubscriptionRenewalRunner.cs @@ -0,0 +1,157 @@ +using Fitness_core_BE.Infra.Tenancy; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Repos; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Services +{ + public sealed record SubscriptionRenewalRunResult( + Guid ClientId, int Candidates, int Renewed, int Failed, IReadOnlyList Notes); + + // The per-schema renewal algorithm, extracted from SubscriptionRenewalJob's daily loop so + // the admin "trigger now" endpoint can call the exact same code path instead of duplicating it. + // + // Renewal-approval model: the daily sweep never touches the CustomerSubscription itself + // (IsActive/RenewalDate/members/installments) -- it only computes whether a new invoice is + // due and creates it as unapproved (IsActive=false). Advancing RenewalDate, and everything + // else that used to happen automatically (deactivate old subscription, create a new one), + // now only happens when staff approve/change-plan/reject via SubscriptionRenewalApprovalService. + public class SubscriptionRenewalRunner(ILogger logger) + { + private readonly ILogger _logger = logger; + + public async Task RunForSchemaAsync( + TenantSchemaContext schemaCtx, DateTime now, CancellationToken ct = default) + { + var tenant = schemaCtx.Tenant; + var central = schemaCtx.Central; + var today = now.Date; + + // DurationMode == "Rolling" is a hard requirement, not a preference: FixedRange + // plans are fixed-date, non-renewing by design. RenewalDate (not ExpiryDate) drives + // eligibility -- ExpiryDate is frozen at creation and never advances. The "no + // already-pending invoice" exclusion is what makes this idempotent: once a renewal + // invoice exists unapproved, the subscription is skipped on every subsequent run + // until an approval action (approve/change-plan/reject) resolves it. + var candidates = await tenant.CustomerSubscriptions + .Include(s => s.CustomerPlan) + .Where(s => s.IsActive + && s.RenewalDate != null && s.RenewalDate < today.AddDays(1) + && s.CustomerPlan.DurationMode == "Rolling" + && s.CustomerPlan.IsActive + && !tenant.CustomerInvoices.Any(i => + i.FKCustomerSubscriptionId == s.SubscriptionId + && i.IsActive == false + && i.Status != "Cancelled")) + .ToListAsync(ct); + + _logger.LogInformation( + "SubscriptionRenewalJob: client {ClientId} -- {Candidates} subscription(s) due for renewal.", + schemaCtx.ClientId, candidates.Count); + + var notes = new List(); + int renewed = 0, failed = 0; + + var uow = new EFUnitOfWork(central, tenant); + var subscriptionRepo = new CustomerSubscriptionRepository(tenant, central); + var invoiceRepo = new CustomerInvoiceRepository(tenant); + + foreach (var oldSubscription in candidates) + { + try + { + await RenewOneAsync(oldSubscription, tenant, uow, subscriptionRepo, invoiceRepo, _logger, ct); + renewed++; + } + catch (Exception ex) + { + await uow.RollbackAsync(ct); + // RollbackAsync only rolls back the DB transaction -- it does not clear EF's + // change tracker. tenant/central are reused across every candidate in this + // loop, so a failed candidate's tracked-but-unsaved mutations (e.g. the + // "RolledOver" status flip) would otherwise still be flushed by the *next* + // candidate's SaveChanges/CommitAsync. + tenant.ChangeTracker.Clear(); + central.ChangeTracker.Clear(); + failed++; + var note = $"Renewal failed for subscription {oldSubscription.SubscriptionId} (customer {oldSubscription.FKCustomerId}): {ex.Message}"; + notes.Add(note); + _logger.LogError(ex, "SubscriptionRenewalJob: {Note}", note); + } + } + + _logger.LogInformation( + "SubscriptionRenewalJob: client {ClientId} done -- {Renewed} renewal invoice(s) created, {Failed} failed.", + schemaCtx.ClientId, renewed, failed); + + return new SubscriptionRenewalRunResult(schemaCtx.ClientId, candidates.Count, renewed, failed, notes); + } + + private static async Task RenewOneAsync( + Fitness_core_BE.Models.CustomerSubscription oldSubscription, + TenantDbContext tenant, + EFUnitOfWork uow, + CustomerSubscriptionRepository subscriptionRepo, + CustomerInvoiceRepository invoiceRepo, + ILogger logger, + CancellationToken ct) + { + await uow.BeginAsync(ct); + + // By construction this is still the real, currently-collectible invoice: the + // candidate query above already excludes subscriptions with a pending-approval + // invoice, and nothing else sets CustomerInvoice.IsActive=false. + var oldInvoice = await subscriptionRepo.GetLatestInvoiceAsync(oldSubscription.SubscriptionId, ct); + if (oldInvoice == null) + { + throw new InvalidOperationException( + $"Subscription {oldSubscription.SubscriptionId} has no invoice to renew from."); + } + + decimal carriedOverBalance; + switch (oldInvoice.Status) + { + case "Paid": + carriedOverBalance = 0m; + logger.LogInformation( + "SubscriptionRenewalJob: subscription {SubscriptionId} -- prior invoice {InvoiceId} was Paid, no balance carried forward.", + oldSubscription.SubscriptionId, oldInvoice.CustomerInvoiceId); + break; + + case "Pending": + case "Partial": + var approvedPaid = await subscriptionRepo.GetApprovedPaymentTotalAsync(oldInvoice.CustomerInvoiceId, ct); + carriedOverBalance = Math.Max(0m, oldInvoice.Amount - approvedPaid); + + // GetLatestInvoiceAsync reads AsNoTracking -- re-fetch tracked to mutate it. + var trackedOldInvoice = await tenant.CustomerInvoices.FindAsync( + new object[] { oldInvoice.CustomerInvoiceId }, ct); + trackedOldInvoice!.Status = "RolledOver"; + + logger.LogInformation( + "SubscriptionRenewalJob: subscription {SubscriptionId} -- prior invoice {InvoiceId} was {OldStatus}, rolling over unpaid balance {CarriedOverBalance} and marking it RolledOver.", + oldSubscription.SubscriptionId, oldInvoice.CustomerInvoiceId, oldInvoice.Status, carriedOverBalance); + break; + + default: + // "Failed"/"Refunded" (or a stray "Cancelled"/"RolledOver" the candidate + // query's own invoice-state shouldn't produce) -- don't guess how to carry + // these forward; surfaces as a PartialFailure note for manual review. + throw new InvalidOperationException( + $"Subscription {oldSubscription.SubscriptionId}'s current invoice has unexpected status '{oldInvoice.Status}'."); + } + + // Renewal invoices skip the onboarding fee (one-time, not a per-cycle charge) and + // start unapproved (IsActive=false) -- staff must approve/change-plan/reject before + // it counts. The subscription itself (IsActive, RenewalDate, members, installments) + // is untouched here; all of that is deferred to the approval action. + var newInvoice = await invoiceRepo.CreateRenewalInvoiceAsync(oldSubscription.SubscriptionId, carriedOverBalance, ct); + + await uow.CommitAsync(ct); + + logger.LogInformation( + "SubscriptionRenewalJob: subscription {SubscriptionId} -- created unapproved renewal invoice {InvoiceId} (amount {Amount}), awaiting staff approval.", + oldSubscription.SubscriptionId, newInvoice.CustomerInvoiceId, newInvoice.Amount); + } + } +} diff --git a/Services/ThirdPartyService.cs b/Services/ThirdPartyService.cs new file mode 100644 index 0000000..649d3e2 --- /dev/null +++ b/Services/ThirdPartyService.cs @@ -0,0 +1,162 @@ +using System.Net; +using System.Net.Mail; +using System.Text.Json; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models.IOs; + +namespace Fitness_core_BE.Services +{ + /// + /// Combines the two outbound notification channels — SMS (Introps) and email (Gmail SMTP) — + /// into one service, matching the pattern already used by Core-Hex-Auth's ThirdPartyService. + /// + public class ThirdPartyService : IThirdPartyService + { + private readonly HttpClient _httpClient; + private readonly IConfiguration _config; + + // Email (Gmail SMTP). Use a Gmail App Password, not the account password. + private readonly string _gmailUser; + private readonly string _gmailPassword; + private readonly string _gmailHost; + private readonly int _gmailPort; + private readonly string _gmailFromName; + + public ThirdPartyService(IHttpClientFactory httpClientFactory, IConfiguration config) + { + _httpClient = httpClientFactory.CreateClient(); + _config = config; + + _gmailUser = Environment.GetEnvironmentVariable("GMAIL_USER") ?? config["Gmail:User"] ?? ""; + _gmailPassword = Environment.GetEnvironmentVariable("GMAIL_APP_PASSWORD") ?? config["Gmail:AppPassword"] ?? ""; + _gmailHost = config["Gmail:Host"] ?? "smtp.gmail.com"; + _gmailPort = int.TryParse(config["Gmail:Port"], out var p) ? p : 587; + _gmailFromName = config["Gmail:FromName"] ?? "GYM OS"; + } + + // ---------- SMS ---------- + + public async Task SendSmsAsync(string mobileNumber, string? otpCode = null, string? message = null) + { + try + { + if (string.IsNullOrWhiteSpace(mobileNumber)) + { + return new OtpResponse + { + StatusCode = 400, + Message = "Mobile number is required" + }; + } + + if (string.IsNullOrWhiteSpace(otpCode) && string.IsNullOrWhiteSpace(message)) + { + return new OtpResponse + { + StatusCode = 400, + Message = "Either an OTP code or a message is required" + }; + } + + string contactNumber = mobileNumber; + string apiUrl = _config["Sms:ApiUrl"] ?? "https://backup.introps.com/Sms_common/send_sms"; + string apiKey = Environment.GetEnvironmentVariable("SMS_API_KEY") ?? _config["Sms:ApiKey"] ?? "8989898989"; + + var msgText = string.IsNullOrWhiteSpace(message) ? $"Your otp code is: {otpCode}" : message; + + var smsRequest = new SmsRequest + { + api_key = apiKey, + phone_number = contactNumber, + msg_text = msgText + }; + + var formContent = new FormUrlEncodedContent(new[] + { + new KeyValuePair("api_key", smsRequest.api_key), + new KeyValuePair("phone_number", smsRequest.phone_number), + new KeyValuePair("msg_text", smsRequest.msg_text) + }); + + var response = await _httpClient.PostAsync(apiUrl, formContent); + var responseContent = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode && responseContent.Contains("agent:d")) + { + string jsonString = responseContent.Split(new[] { "agent:d" }, StringSplitOptions.None)[1]; + + var responseJson = JsonSerializer.Deserialize>(jsonString); + + if (responseJson is not null && + responseJson.TryGetValue("stt", out var statusValue) && + statusValue?.ToString() == "ok") + { + Console.WriteLine($"SMS sent to {contactNumber}"); + return new OtpResponse + { + StatusCode = 200, + Message = "SMS sent successfully." + }; + } + } + + Console.WriteLine($"Failed to send SMS to {contactNumber}. Response: {responseContent}"); + return new OtpResponse + { + StatusCode = 500, + Message = "Failed to send SMS.", + ApiResponse = responseContent + }; + } + catch (Exception ex) + { + Console.WriteLine($"Exception sending SMS: {ex.Message}"); + + return new OtpResponse + { + StatusCode = 500, + Message = "Exception occurred while sending SMS.", + Error = ex.Message + }; + } + } + + // ---------- Email ---------- + + public async Task SendEmailAsync(string toEmail, string subject, string htmlBody, byte[]? attachment = null, string? attachmentName = null) + { + if (string.IsNullOrWhiteSpace(_gmailUser) || string.IsNullOrWhiteSpace(_gmailPassword)) + throw new InvalidOperationException("Gmail SMTP is not configured (set GMAIL_USER / GMAIL_APP_PASSWORD)."); + + using var mail = new MailMessage + { + From = new MailAddress(_gmailUser, _gmailFromName), + Subject = subject, + Body = htmlBody, + IsBodyHtml = true + }; + mail.To.Add(toEmail); + + MemoryStream? attachmentStream = null; + if (attachment != null) + { + attachmentStream = new MemoryStream(attachment); + mail.Attachments.Add(new Attachment(attachmentStream, attachmentName ?? "receipt.pdf", "application/pdf")); + } + + try + { + using var client = new SmtpClient(_gmailHost, _gmailPort) + { + EnableSsl = true, + Credentials = new NetworkCredential(_gmailUser, _gmailPassword) + }; + await client.SendMailAsync(mail); + } + finally + { + attachmentStream?.Dispose(); + } + } + } +} diff --git a/Services/TicketService.cs b/Services/TicketService.cs new file mode 100644 index 0000000..86b56da --- /dev/null +++ b/Services/TicketService.cs @@ -0,0 +1,327 @@ +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using System.Net.Sockets; + +namespace Fitness_core_BE.Services.Ticket +{ + public class TicketService : ITicketService + { + private readonly ITicketRepository _repository; + private readonly UserRoleHelper _userRoleHelper; + private readonly IUnitOfWork _uow; + private readonly IActivityLogService _activityLogService; + + public TicketService(ITicketRepository repository, UserRoleHelper userRoleHelper, IUnitOfWork uow, IActivityLogService activityLogService) + { + _repository = repository; + _userRoleHelper = userRoleHelper; + _uow = uow; + _activityLogService = activityLogService; + } + + public async Task GetTicketList(object payload, HttpContext httpContext) + { + try + { + var tickets = await _repository.GetTicketListAsync(); + return tickets; + } + catch + { + throw; + } + } + + public async Task GetTicketByUserId(object payload, HttpContext httpContext) + { + try + { + // Guid userId = Guid.TryParse(httpContext.Items["UserId"]?.ToString(), out var parsedUserId) + // ? parsedUserId + // : throw new UnauthorizedAccessException("User ID not found in context"); + // Guid userId = Guid.Parse("a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d"); // Testing purposes + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + + var tickets = await _repository.GetTicketByUserIdAsync(userId); + return tickets; + } + catch + { + throw; + } + } + + public async Task GetTicketById(object payload, HttpContext httpContext) + { + try + { + var data = DeserializePayload.Deserialize(payload); + Guid ticketId = Guid.Parse(data["ticketId"].GetString()!); + + var ticket = await _repository.GetByTicketIdAsync(ticketId); + + if (ticket == null) + throw new KeyNotFoundException($"Ticket with ID {ticketId} not found"); + if (ticket.UserId != DataExtractor.GetUserIdFromContext(httpContext)) + throw new UnauthorizedAccessException("You do not have permission to view this ticket."); + + return ticket; + } + catch + { + throw; + } + } + + public async Task GetTicketCategoryList(object payload, HttpContext httpContext) + { + try + { + var categories = await _repository.GetTicketCategoryListAsync(); + return categories; + } + catch + { + throw; + } + } + + public async Task CreateTicket(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + // Parse userId from context + // Guid userId = Guid.TryParse(httpContext.Items["UserId"]?.ToString(), out var parsedUserId) + // ? parsedUserId + // : throw new UnauthorizedAccessException("User ID not found in context"); + // Guid userId = Guid.Parse("a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d"); // Testing purposes + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + + var ticket = new Fitness_core_BE.Models.Ticket + { + MainReason = data["mainReason"].GetString()!, + FKTicketCategoryId = data.ContainsKey("ticketCategoryId") && !string.IsNullOrEmpty(data["ticketCategoryId"].GetString()) + ? Guid.Parse(data["ticketCategoryId"].GetString()!) + : (Guid?)null, + TicketStatus = data.ContainsKey("ticketStatus") && !string.IsNullOrEmpty(data["ticketStatus"].GetString()) + ? Enum.Parse(data["ticketStatus"].GetString()!) + : Fitness_core_BE.Models.Enums.TicketStatusEnum.Pending, + FKUserId = userId, + CreateDate = DateTime.UtcNow + }; + + await _repository.CreateTicketAsync(ticket); + + await _uow.CommitAsync(); + + // Log activity - non-blocking, failures won't crash the operation + try + { + await _activityLogService.LogActivity(new { description = $"Ticket created with ID {ticket.TicketId}" }, httpContext); + } + catch (Exception logEx) + { + Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}"); + } + + return ticket; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task UpdateTicket(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + Guid ticketId = Guid.Parse(data["ticketId"].GetString()!); + var ticket = await _repository.GetByTicketIdAsync(ticketId) ?? throw new KeyNotFoundException($"Ticket with ID {ticketId} not found."); + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + if (ticket.UserId != userId) + throw new UnauthorizedAccessException("You do not have permission to update this ticket."); + + ticket.MainReason = data.ContainsKey("mainReason") && !string.IsNullOrEmpty(data["mainReason"].GetString()) + ? data["mainReason"].GetString()! + : ticket.MainReason; + ticket.TicketCategoryId = data.ContainsKey("ticketCategoryId") && !string.IsNullOrEmpty(data["ticketCategoryId"].GetString()) + ? Guid.Parse(data["ticketCategoryId"].GetString()!) + : ticket.TicketCategoryId; + + await _repository.UpdateTicketAsync(ticket); + await _uow.CommitAsync(); + return ticket; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task SendMessage(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + // Parse userId from context + // Guid userId = Guid.TryParse(httpContext.Items["UserId"]?.ToString(), out var parsedUserId) + // ? parsedUserId + // : throw new UnauthorizedAccessException("User ID not found in context"); + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + + Guid? adminId = await _userRoleHelper.GetAdminId(userId); + bool isAdmin = adminId.HasValue; + Guid ticketId = Guid.Parse(data["ticketId"].GetString()!); + var ticket = await _repository.GetByTicketIdAsync(ticketId) + ?? throw new KeyNotFoundException($"Ticket with ID {ticketId} not found."); + if (!isAdmin && ticket.UserId != userId) + throw new UnauthorizedAccessException("You do not have permission to message this ticket."); + + var ticketMessage = new TicketMessage + { + Message = data["message"].GetString()!, + FKTicketId = ticketId, + IsSentByAdmin = isAdmin, + AttachmentURL = data.ContainsKey("attachmentURL") ? data["attachmentURL"].GetString() : null, + FKDocumentId = DataExtractor.GetNullableInt(data, "documentId"), + CreateDate = DateTime.UtcNow + }; + + // First admin reply moves a ticket from Pending to Open — nothing previously + // set this transition, so tickets sat in Pending forever once created. + if (isAdmin) + { + if (ticket.TicketStatus == Models.Enums.TicketStatusEnum.Pending) + { + await _repository.UpdateTicketStatusAsync(ticketId, Models.Enums.TicketStatusEnum.Open); + } + } + + //Guid ticCatId = DataExtractor.GetRequiredGuid(data, "ticketCategoryId"); + + //Guid? TcatId = await _repository.GetTicketCatId("Chat"); + + //if (ticCatId == TcatId) + //{ + + //} + + await _repository.SendMessageAsync(ticketMessage); + await _uow.CommitAsync(); + return ticketMessage; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task SubmitTicketFeedback(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + Guid ticketId = Guid.Parse(data["ticketId"].GetString()!); + int rating = DataExtractor.GetRequiredInt(data, "rating"); + string? feedback = data.TryGetValue("feedback", out var feedbackContent) && !string.IsNullOrEmpty(feedbackContent.GetString()) + ? DataExtractor.GetOptionalString(data, "feedback") + : null; + + if (rating < 1 || rating > 5) + throw new ArgumentException("Rating must be between 1 and 5."); + + var ticket = await _repository.GetByTicketIdAsync(ticketId) ?? throw new KeyNotFoundException($"Ticket with ID {ticketId} not found."); + if (ticket.UserId != DataExtractor.GetUserIdFromContext(httpContext)) + throw new UnauthorizedAccessException("You do not have permission to submit feedback for this ticket."); + if (ticket.TicketStatus != Models.Enums.TicketStatusEnum.Closed) + throw new InvalidOperationException("Feedback can only be submitted after the ticket is closed."); + + await _repository.SubmitTicketFeedbackAsync(ticketId, rating, feedback); + await _uow.CommitAsync(); + return new { message = $"The feedback for ticket with ID {ticketId} submitted successfully." }; + + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task CloseTicket(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + Guid ticketId = Guid.Parse(data["ticketId"].GetString()!); + var ticket = await _repository.GetByTicketIdAsync(ticketId) + ?? throw new KeyNotFoundException($"Ticket with ID {ticketId} not found."); + if (ticket.UserId != DataExtractor.GetUserIdFromContext(httpContext)) + throw new UnauthorizedAccessException("You do not have permission to close this ticket."); + + await _repository.UpdateTicketStatusAsync(ticketId, Fitness_core_BE.Models.Enums.TicketStatusEnum.Closed); + await _uow.CommitAsync(); + + var updatedTicket = await _repository.GetByTicketIdAsync(ticketId); + + if (updatedTicket == null) + throw new KeyNotFoundException($"Ticket with ID {ticketId} not found after update"); + + return updatedTicket; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task DeleteTicket(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + Guid ticketId = Guid.Parse(data["ticketId"].GetString()!); + var ticket = await _repository.GetByTicketIdAsync(ticketId) ?? throw new KeyNotFoundException($"Ticket with ID {ticketId} not found."); + + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + if (ticket.UserId != userId) + throw new UnauthorizedAccessException("You do not have permission to delete this ticket."); + + await _repository.DeleteAsync(ticketId); + await _uow.CommitAsync(); + + var deletedTicket = await _repository.GetByTicketIdAsync(ticketId); + + if (deletedTicket == null) + return true; + + throw new ArgumentException($"Ticket with ID {ticketId} has not been deleted"); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + } +} diff --git a/Services/TimeBankCardService.cs b/Services/TimeBankCardService.cs new file mode 100644 index 0000000..f3b2103 --- /dev/null +++ b/Services/TimeBankCardService.cs @@ -0,0 +1,164 @@ +using Fitness_core_BE.DTOs.TimeBankCard; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models; +using System.Text.Json; + +namespace Fitness_core_BE.Services +{ + public class TimeBankCardService : ITimeBankCardService + { + private readonly ITimeBankCardRepository _timeBankCardRepository; + private readonly IUnitOfWork _uow; + private readonly UserRoleHelper _userRoleHelper; + + public TimeBankCardService(ITimeBankCardRepository timeBankCardRepository, IUnitOfWork uow, UserRoleHelper userRoleHelper) + { + _timeBankCardRepository = timeBankCardRepository; + _uow = uow; + _userRoleHelper = userRoleHelper; + } + + public async Task IssueCard(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + var rfidTagId = data["rfidTagId"].GetString() ?? throw new ArgumentException("rfidTagId is required"); + var totalMinutes = data["totalMinutesPurchased"].GetInt32(); + if (totalMinutes < 1) throw new ArgumentException("totalMinutesPurchased must be at least 1"); + + if (await _timeBankCardRepository.GetByRfidTagId(rfidTagId) != null) + throw new ArgumentException("This RFID tag already has an active time-bank card"); + + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + + var card = new TimeBankCard + { + FKClientId = clientId, + RfidTagId = rfidTagId, + TotalMinutesPurchased = totalMinutes, + MinutesRemaining = totalMinutes, + AmountPaid = data.ContainsKey("amountPaid") ? data["amountPaid"].GetDecimal() : 0m, + FKCustomerId = data.ContainsKey("customerId") && data["customerId"].ValueKind != JsonValueKind.Null + ? Guid.Parse(data["customerId"].GetString()!) + : null, + ExpiresAt = data.ContainsKey("expiresAt") && data["expiresAt"].ValueKind != JsonValueKind.Null + ? DateTime.Parse(data["expiresAt"].GetString()!).ToUniversalTime() + : null + }; + + var result = await _timeBankCardRepository.CreateTimeBankCard(card); + await _uow.CommitAsync(); + + return MapToDto(result); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task Swipe(object payload) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + var rfidTagId = data["rfidTagId"].GetString() ?? throw new ArgumentException("rfidTagId is required"); + var facilityId = Guid.Parse(data["facilityId"].GetString()!); + + var card = await _timeBankCardRepository.GetByRfidTagId(rfidTagId) + ?? throw new KeyNotFoundException("No active time-bank card found for this RFID tag"); + + if (card.ExpiresAt.HasValue && card.ExpiresAt.Value < DateTime.UtcNow) + throw new ArgumentException("This card has expired"); + + int? minutesDeducted = null; + + if (!card.ActiveSessionStartedAt.HasValue) + { + // Tap-in: just open the session. Reject outright if there's no balance left + // at all, so a spent card doesn't get to "start the clock" on a visit it + // can never pay for. + if (card.MinutesRemaining < 1) + throw new ArgumentException("This card has no time remaining"); + + card.ActiveSessionStartedAt = DateTime.UtcNow; + card.ActiveSessionFacilityId = facilityId; + } + else + { + // Tap-out: bill for actual elapsed time, rounded up to the next whole minute + // (a 61-second visit still costs 2 minutes, not 1) -- capped at whatever + // balance remains rather than letting a forgotten tap-out run the balance + // negative. + var elapsed = DateTime.UtcNow - card.ActiveSessionStartedAt.Value; + var elapsedMinutes = Math.Max(1, (int)Math.Ceiling(elapsed.TotalMinutes)); + minutesDeducted = Math.Min(elapsedMinutes, card.MinutesRemaining); + + card.MinutesRemaining -= minutesDeducted.Value; + + await _timeBankCardRepository.AddUsageLog(new TimeBankCardUsageLog + { + FKTimeBankCardId = card.TimeBankCardId, + FKClientId = card.FKClientId, + FKFacilityId = card.ActiveSessionFacilityId ?? facilityId, + MinutesDeducted = minutesDeducted.Value, + MinutesRemainingAfter = card.MinutesRemaining + }); + + card.ActiveSessionStartedAt = null; + card.ActiveSessionFacilityId = null; + } + + await _uow.CommitAsync(); + return MapToDto(card, minutesDeducted); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task GetBalance(object payload) + { + var data = DeserializePayload.Deserialize(payload); + var rfidTagId = data["rfidTagId"].GetString() ?? throw new ArgumentException("rfidTagId is required"); + + var card = await _timeBankCardRepository.GetByRfidTagId(rfidTagId) + ?? throw new KeyNotFoundException("No active time-bank card found for this RFID tag"); + + return MapToDto(card); + } + + public async Task GetMyCards(object payload, HttpContext httpContext) + { + var cusId = await _userRoleHelper.GetCusId(DataExtractor.GetUserIdFromContext(httpContext)); + var cards = await _timeBankCardRepository.GetByCustomerId(cusId); + return cards.Select(c => MapToDto(c)).ToList(); + } + + private static TimeBankCardDto MapToDto(TimeBankCard card, int? minutesDeductedThisSwipe = null) => new() + { + TimeBankCardId = card.TimeBankCardId, + RfidTagId = card.RfidTagId, + CustomerId = card.FKCustomerId, + TotalMinutesPurchased = card.TotalMinutesPurchased, + MinutesRemaining = card.MinutesRemaining, + AmountPaid = card.AmountPaid, + PurchasedAt = card.PurchasedAt, + ExpiresAt = card.ExpiresAt, + IsActive = card.IsActive, + SessionOpen = card.ActiveSessionStartedAt.HasValue, + SessionStartedAt = card.ActiveSessionStartedAt, + SessionFacilityName = card.ActiveSessionFacility?.Name, + MinutesDeductedThisSwipe = minutesDeductedThisSwipe + }; + } +} diff --git a/Services/WorkoutService.cs b/Services/WorkoutService.cs new file mode 100644 index 0000000..0e3b25b --- /dev/null +++ b/Services/WorkoutService.cs @@ -0,0 +1,1180 @@ +using Fitness_core_BE.Models; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Infra.UoW; +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Exceptions; + +namespace Fitness_core_BE.Services.Workout +{ + public class WorkoutService : IWorkoutService + { + private readonly IWorkoutRepository _repository; + private readonly UserRoleHelper _userRoleHelper; + private readonly IUnitOfWork _uow; + private readonly IActivityLogService _activityLogService; + private readonly IPushNotificationService _pushNotificationService; + + public WorkoutService(IWorkoutRepository repository, UserRoleHelper userRoleHelper, IUnitOfWork uow, IActivityLogService activityLogService, IPushNotificationService pushNotificationService) + { + _repository = repository; + _userRoleHelper = userRoleHelper; + _uow = uow; + _activityLogService = activityLogService; + _pushNotificationService = pushNotificationService; + } + + private static string? GetOptionalJsonText(System.Text.Json.JsonElement source, params string[] propertyNames) + { + foreach (var propertyName in propertyNames) + { + if (!source.TryGetProperty(propertyName, out var value) || + value.ValueKind is System.Text.Json.JsonValueKind.Null or System.Text.Json.JsonValueKind.Undefined) + continue; + + return value.ValueKind == System.Text.Json.JsonValueKind.String + ? value.GetString() + : value.GetRawText(); + } + + return null; + } + + private static string? GetOptionalJsonText( + Dictionary source, + params string[] propertyNames) + { + foreach (var propertyName in propertyNames) + { + if (!source.TryGetValue(propertyName, out var value) || + value.ValueKind is System.Text.Json.JsonValueKind.Null or System.Text.Json.JsonValueKind.Undefined) + continue; + + return value.ValueKind == System.Text.Json.JsonValueKind.String + ? value.GetString() + : value.GetRawText(); + } + + return null; + } + + // Parses a day's "exercises" array (structured objects with name/muscleGroup/sets/reps/ + // description/breathIn/breathOut/videoUrl) into real WorkoutExercise rows, mirroring + // AdminSuggestionService's persistence for admin-authored suggestions. Legacy plain-string + // exercise entries (no "name" property) are skipped here but still preserved in the + // WorkoutItem.Exercises JSON blob for backward compatibility. + private static List ParseWorkoutExercisesArray(System.Text.Json.JsonElement exercisesElement) + { + var result = new List(); + if (exercisesElement.ValueKind != System.Text.Json.JsonValueKind.Array) return result; + + int order = 1; + foreach (var ex in exercisesElement.EnumerateArray()) + { + if (ex.ValueKind != System.Text.Json.JsonValueKind.Object) continue; + if (!ex.TryGetProperty("name", out var nameEl) || nameEl.ValueKind != System.Text.Json.JsonValueKind.String) continue; + var name = nameEl.GetString(); + if (string.IsNullOrWhiteSpace(name)) continue; + + int? sets = ex.TryGetProperty("sets", out var s) && s.ValueKind == System.Text.Json.JsonValueKind.Number ? s.GetInt32() : (int?)null; + + result.Add(new WorkoutExercise + { + WorkoutExerciseId = Guid.NewGuid(), + Name = name.Trim(), + MuscleGroup = GetOptionalJsonText(ex, "muscleGroup"), + Sets = sets, + Reps = GetOptionalJsonText(ex, "reps"), + Description = GetOptionalJsonText(ex, "description"), + BreathIn = GetOptionalJsonText(ex, "breathIn"), + BreathOut = GetOptionalJsonText(ex, "breathOut"), + VideoUrl = GetOptionalJsonText(ex, "videoUrl", "videoURL", "doURL"), + SortOrder = order++, + IsActive = true, + CreateDate = DateTime.UtcNow + }); + } + + return result; + } + + private static List ParseWorkoutExercises(System.Text.Json.JsonElement itemElement) => + itemElement.TryGetProperty("exercises", out var exercisesElement) ? ParseWorkoutExercisesArray(exercisesElement) : new List(); + + private static List ParseWorkoutExercises(Dictionary source) => + source.TryGetValue("exercises", out var exercisesElement) ? ParseWorkoutExercisesArray(exercisesElement) : new List(); + + // Workout Category operations + public async Task GetWorkoutCategoryList(object payload, HttpContext httpContext) + { + try + { + var categories = await _repository.GetWorkoutCategoryListAsync(); + return categories; + } + catch + { + throw; + } + } + + public async Task CreateWorkoutCategory(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + var workoutCategory = new WorkoutCategory + { + WorkoutCategoryName = data["name"].GetString()!, + Description = data.ContainsKey("description") ? data["description"].GetString() : null, + CreateDate = DateTime.UtcNow + }; + + await _repository.CreateWorkoutCategoryAsync(workoutCategory); + await _uow.CommitAsync(); + + // Log activity - non-blocking, failures won't crash the operation + try + { + await _activityLogService.LogActivity(new { description = $"Workout category created with ID {workoutCategory.WorkoutCategoryId}" }, httpContext); + } + catch (Exception logEx) + { + Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}"); + } + + return workoutCategory; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + // Workout Plan operations + public async Task GetWorkoutPlanList(object payload, HttpContext httpContext) + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + string userType = await _userRoleHelper.GetUserType(userId); + Guid clientId; + if (userType == "Customer") + { + Guid cusId = await _userRoleHelper.GetCusId(userId); + try + { + clientId = await _userRoleHelper.GetClientIdByCusId(cusId); + } + catch (NotFoundException) + { + // Not every customer has joined a gym yet — they should still see the + // platform-wide admin suggestions (FKClientId == null in the repo query). + clientId = Guid.Empty; + } + } + else + { + clientId = await _userRoleHelper.GetClientId(userId); + } + try + { + var workoutPlans = await _repository.GetWorkoutPlanListAsync(userType, clientId); + return workoutPlans; + } + catch + { + throw; + } + } + + public async Task GetWorkoutPlanById(object payload, HttpContext httpContext) + { + try + { + var data = DeserializePayload.Deserialize(payload); + Guid workoutPlanId = Guid.Parse(data["workoutPlanId"].GetString()!); + + var workoutPlan = await _repository.GetWorkoutPlanByIdAsync(workoutPlanId); + + if (workoutPlan == null) + throw new NotFoundException($"Workout plan with ID {workoutPlanId} not found"); + + // Transform workout items to have exercises as objects instead of JSON strings + var transformedWorkoutItems = workoutPlan.WorkoutItems?.Select(item => + { + object? exercisesObj = null; + if (!string.IsNullOrEmpty(item.Exercises)) + { + try + { + exercisesObj = System.Text.Json.JsonSerializer.Deserialize(item.Exercises); + } + catch + { + exercisesObj = item.Exercises; // Keep as string if parsing fails + } + } + + return new + { + item.WorkoutItemId, + item.ExerciseDay, + item.Description, + Exercises = exercisesObj, + item.WorkoutExercises, + item.TotalExercises, + item.HowToDo, + item.DoURL, + item.Duration, + item.WormUp, + item.Cooldown, + item.WorkoutPlanId, + item.IsActive, + item.CreateDate, + item.UpdatedAt + }; + }).ToList(); + + return new + { + workoutPlan.WorkoutPlanId, + workoutPlan.Name, + workoutPlan.Description, + workoutPlan.IsTemplate, + workoutPlan.WorkoutCategoryId, + workoutPlan.WorkoutCategoryName, + workoutPlan.ClientId, + workoutPlan.ClientName, + workoutPlan.IsActive, + workoutPlan.CreateDate, + WorkoutItems = transformedWorkoutItems, + AssignedCustomers = workoutPlan.AssignedCustomers + }; + } + catch + { + throw; + } + } + + public async Task GetWorkoutPlanByCustomerId(object payload, HttpContext httpContext) + { + try + { + Guid customerId; + var data = DeserializePayload.Deserialize(payload); + + if (data.ContainsKey("customerId") && + !string.IsNullOrEmpty(data["customerId"].GetString())) + { + customerId = Guid.Parse(data["customerId"].GetString()!); + } + else + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + customerId = await _userRoleHelper.GetCusId(userId); + } + + var workoutPlans = await _repository.GetWorkoutPlansByCustomerIdAsync(customerId); + + var transformedPlans = workoutPlans.Select(plan => new + { + plan.WorkoutPlanId, + plan.Name, + plan.Description, + plan.IsTemplate, + plan.WorkoutCategoryId, + plan.WorkoutCategoryName, + plan.ClientId, + plan.ClientName, + plan.IsActive, + plan.CreateDate, + WorkoutItems = plan.WorkoutItems?.Select(item => + { + object? exercisesObj = null; + if (!string.IsNullOrEmpty(item.Exercises)) + { + try + { + exercisesObj = System.Text.Json.JsonSerializer.Deserialize(item.Exercises); + } + catch + { + exercisesObj = item.Exercises; + } + } + + return new + { + item.WorkoutItemId, + item.ExerciseDay, + item.Description, + Exercises = exercisesObj, + item.HowToDo, + item.DoURL, + item.Duration, + item.WormUp, + item.Cooldown, + item.WorkoutPlanId, + item.IsActive, + item.CreateDate, + item.UpdatedAt + }; + }).ToList(), + AssignedCustomers = plan.AssignedCustomers + }).ToList(); + + return transformedPlans; + } + catch + { + throw; + } + } + + public async Task CreateWorkoutPlan(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + Guid clientId = await _userRoleHelper.GetClientId(userId); + + + var workoutCategoryId = Guid.Parse(data["workoutCategoryId"].GetString()!); + if (!await _repository.WorkoutCategoryExistsAsync(workoutCategoryId)) + throw new NotFoundException("Workout category not found."); + + var workoutPlan = new WorkoutPlan + { + WorkoutPlanName = data["name"].GetString()!, + Description = data.ContainsKey("description") ? data["description"].GetString() : null, + IsTemplate = data.ContainsKey("isTemplate") && data["isTemplate"].GetBoolean(), + FKWorkoutCategoryId = workoutCategoryId, + FKClientId = clientId, + CreateDate = DateTime.UtcNow + }; + + await _repository.CreateWorkoutPlanAsync(workoutPlan); + + + if (data.ContainsKey("customerIds") && data["customerIds"].ValueKind == System.Text.Json.JsonValueKind.Array) + { + var customerIdsArray = data["customerIds"].EnumerateArray(); + foreach (var customerIdElement in customerIdsArray) + { + if (Guid.TryParse(customerIdElement.GetString(), out var customerId)) + { + var customerWorkout = new CustomerWorkout + { + FKCustomerId = customerId, + FKWorkoutPlanId = workoutPlan.WorkoutPlanId, + AssignedDate = DateTime.UtcNow, + IsActive = true + }; + await _repository.AssignWorkoutToCustomerAsync(customerWorkout); + } + } + } + else if (data.ContainsKey("customerId") && !string.IsNullOrEmpty(data["customerId"].GetString())) + { + var customerId = Guid.Parse(data["customerId"].GetString()!); + var customerWorkout = new CustomerWorkout + { + FKCustomerId = customerId, + FKWorkoutPlanId = workoutPlan.WorkoutPlanId, + AssignedDate = DateTime.UtcNow, + IsActive = true + }; + await _repository.AssignWorkoutToCustomerAsync(customerWorkout); + } + + if (data.ContainsKey("workoutItems") && data["workoutItems"].ValueKind == System.Text.Json.JsonValueKind.Array) + { + var workoutItemsArray = data["workoutItems"].EnumerateArray(); + foreach (var item in workoutItemsArray) + { + // Handle exercises JSON + string? exercisesJson = null; + if (item.TryGetProperty("exercises", out var exercisesElement)) + { + if (exercisesElement.ValueKind == System.Text.Json.JsonValueKind.Array || + exercisesElement.ValueKind == System.Text.Json.JsonValueKind.Object) + { + exercisesJson = exercisesElement.GetRawText(); + } + else if (exercisesElement.ValueKind == System.Text.Json.JsonValueKind.String) + { + exercisesJson = exercisesElement.GetString(); + } + } + + var workoutItem = new WorkoutItem + { + ExerciseDay = item.GetProperty("exerciseDay").GetString()!, + Description = item.TryGetProperty("description", out var descProp) ? descProp.GetString() : null, + Exercises = exercisesJson, + HowToDo = GetOptionalJsonText(item, "howToDo"), + DoURL = GetOptionalJsonText(item, "doURL", "videoUrl", "videoURL"), + FKWorkoutPlanId = workoutPlan.WorkoutPlanId, + CreateDate = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + Duration = GetOptionalJsonText(item, "duration"), + WormUp = GetOptionalJsonText(item, "wormUp", "warmUp", "warmup"), + Cooldown = GetOptionalJsonText(item, "cooldown", "coolDown"), + }; + workoutItem.WorkoutExercises = ParseWorkoutExercises(item); + + await _repository.CreateWorkoutItemAsync(workoutItem); + } + } + + await _uow.CommitAsync(); + + // Get the created plan with items + var createdWorkoutPlan = await _repository.GetWorkoutPlanByIdAsync(workoutPlan.WorkoutPlanId); + + if (createdWorkoutPlan == null) + throw new InvalidOperationException("Failed to retrieve created workout plan"); + + // Transform workout items to have exercises as objects instead of JSON strings + var transformedWorkoutItems = createdWorkoutPlan.WorkoutItems?.Select(item => + { + object? exercisesObj = null; + if (!string.IsNullOrEmpty(item.Exercises)) + { + try + { + exercisesObj = System.Text.Json.JsonSerializer.Deserialize(item.Exercises); + } + catch + { + exercisesObj = item.Exercises; // Keep as string if parsing fails + } + } + + return new + { + item.WorkoutItemId, + item.ExerciseDay, + item.Description, + Exercises = exercisesObj, + item.WorkoutExercises, + item.TotalExercises, + item.HowToDo, + item.DoURL, + item.Duration, + item.WormUp, + item.Cooldown, + item.WorkoutPlanId, + item.IsActive, + item.CreateDate, + item.UpdatedAt + }; + }).ToList(); + + try + { + await _activityLogService.LogActivity(new { description = $"Workout plan created with ID {workoutPlan.WorkoutPlanId}" }, httpContext); + } + catch (Exception logEx) + { + Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}"); + } + + return new + { + createdWorkoutPlan.WorkoutPlanId, + createdWorkoutPlan.Name, + createdWorkoutPlan.Description, + createdWorkoutPlan.IsTemplate, + createdWorkoutPlan.WorkoutCategoryId, + createdWorkoutPlan.WorkoutCategoryName, + createdWorkoutPlan.ClientId, + createdWorkoutPlan.ClientName, + createdWorkoutPlan.IsActive, + createdWorkoutPlan.CreateDate, + WorkoutItems = transformedWorkoutItems, + AssignedCustomers = createdWorkoutPlan.AssignedCustomers + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + // Same rationale as MealService's equivalent: WorkoutCategory has no FKClientId (it's a + // global taxonomy), so a single well-known "AI Generated" category is get-or-created + // and reused for every gym's AI-generated workout plans, instead of the mobile app + // guessing a category by fetching the list and taking the first entry. + private const string AiGeneratedCategoryName = "AI Generated"; + + private async Task GetOrCreateAiWorkoutCategoryIdAsync() + { + var categories = await _repository.GetWorkoutCategoryListAsync(); + var existing = categories.FirstOrDefault(c => + string.Equals(c.Name, AiGeneratedCategoryName, StringComparison.OrdinalIgnoreCase)); + if (existing != null) + return existing.WorkoutCategoryId; + + var created = await _repository.CreateWorkoutCategoryAsync(new WorkoutCategory + { + WorkoutCategoryId = Guid.NewGuid(), + WorkoutCategoryName = AiGeneratedCategoryName, + Description = "Plans generated by the AI workout planner", + IsActive = true, + CreateDate = DateTime.UtcNow + }); + return created.WorkoutCategoryId; + } + + public async Task CreateCustomerWorkout(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + Guid customerId; + if (data.ContainsKey("customerId") && + !string.IsNullOrEmpty(data["customerId"].GetString())) + { + customerId = Guid.Parse(data["customerId"].GetString()!); + } + else + { + Guid userId = DataExtractor.GetUserIdFromContext(httpContext); + customerId = await _userRoleHelper.GetCusId(userId); + } + + // The plan belongs to the customer's own gym, not a fixed placeholder client. + Guid clientId = await _userRoleHelper.GetClientIdByCusId(customerId); + + Guid workoutCategoryId = data.ContainsKey("workoutCategoryId") && !string.IsNullOrEmpty(data["workoutCategoryId"].GetString()) + ? Guid.Parse(data["workoutCategoryId"].GetString()!) + : await GetOrCreateAiWorkoutCategoryIdAsync(); + + var workoutPlan = new WorkoutPlan + { + WorkoutPlanName = data["name"].GetString()!, + Description = data.ContainsKey("description") ? data["description"].GetString() : null, + IsTemplate = data.ContainsKey("isTemplate") && data["isTemplate"].GetBoolean(), + FKWorkoutCategoryId = workoutCategoryId, + FKClientId = clientId, + CreateDate = DateTime.UtcNow + }; + + await _repository.CreateWorkoutPlanAsync(workoutPlan); + + if (data.ContainsKey("workoutItems") && data["workoutItems"].ValueKind == System.Text.Json.JsonValueKind.Array) + { + var workoutItemsArray = data["workoutItems"].EnumerateArray(); + foreach (var item in workoutItemsArray) + { + string? exercisesJson = null; + if (item.TryGetProperty("exercises", out var exercisesElement)) + { + if (exercisesElement.ValueKind == System.Text.Json.JsonValueKind.Array || + exercisesElement.ValueKind == System.Text.Json.JsonValueKind.Object) + { + exercisesJson = exercisesElement.GetRawText(); + } + else if (exercisesElement.ValueKind == System.Text.Json.JsonValueKind.String) + { + exercisesJson = exercisesElement.GetString(); + } + } + + var workoutItem = new WorkoutItem + { + ExerciseDay = item.GetProperty("exerciseDay").GetString()!, + Description = item.TryGetProperty("description", out var descProp) ? descProp.GetString() : null, + Exercises = exercisesJson, + HowToDo = GetOptionalJsonText(item, "howToDo"), + DoURL = GetOptionalJsonText(item, "doURL", "videoUrl", "videoURL"), + FKWorkoutPlanId = workoutPlan.WorkoutPlanId, + CreateDate = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + Duration = GetOptionalJsonText(item, "duration"), + WormUp = GetOptionalJsonText(item, "wormUp", "warmUp", "warmup"), + Cooldown = GetOptionalJsonText(item, "cooldown", "coolDown"), + }; + + await _repository.CreateWorkoutItemAsync(workoutItem); + } + } + + var customerWorkout = new CustomerWorkout + { + FKCustomerId = customerId, + FKWorkoutPlanId = workoutPlan.WorkoutPlanId, + AssignedDate = DateTime.UtcNow, + IsActive = true, + IsFollowing = true + }; + await _repository.AssignWorkoutToCustomerAsync(customerWorkout); + + await _uow.CommitAsync(); + + var createdWorkoutPlan = await _repository.GetWorkoutPlanByIdAsync(workoutPlan.WorkoutPlanId); + + if (createdWorkoutPlan == null) + throw new InvalidOperationException("Failed to retrieve created workout plan"); + + var transformedWorkoutItems = createdWorkoutPlan.WorkoutItems?.Select(item => + { + object? exercisesObj = null; + if (!string.IsNullOrEmpty(item.Exercises)) + { + try + { + exercisesObj = System.Text.Json.JsonSerializer.Deserialize(item.Exercises); + } + catch + { + exercisesObj = item.Exercises; + } + } + + return new + { + item.WorkoutItemId, + item.ExerciseDay, + item.Description, + Exercises = exercisesObj, + item.WorkoutExercises, + item.TotalExercises, + item.HowToDo, + item.DoURL, + item.Duration, + item.WormUp, + item.Cooldown, + item.WorkoutPlanId, + item.IsActive, + item.CreateDate, + item.UpdatedAt + }; + }).ToList(); + + try + { + await _activityLogService.LogActivity(new { description = $"Customer workout plan created with ID {workoutPlan.WorkoutPlanId}" }, httpContext); + } + catch (Exception logEx) + { + Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}"); + } + + return new + { + createdWorkoutPlan.WorkoutPlanId, + createdWorkoutPlan.Name, + createdWorkoutPlan.Description, + createdWorkoutPlan.IsTemplate, + createdWorkoutPlan.WorkoutCategoryId, + createdWorkoutPlan.WorkoutCategoryName, + createdWorkoutPlan.ClientId, + createdWorkoutPlan.ClientName, + createdWorkoutPlan.IsActive, + createdWorkoutPlan.CreateDate, + WorkoutItems = transformedWorkoutItems, + AssignedCustomers = createdWorkoutPlan.AssignedCustomers + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task UpdateWorkoutPlan(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + Guid workoutPlanId = Guid.Parse(data["workoutPlanId"].GetString()!); + + var existingWorkoutPlan = await _repository.GetWorkoutPlanEntityByIdAsync(workoutPlanId); + if (existingWorkoutPlan == null) + throw new KeyNotFoundException($"Workout plan with ID {workoutPlanId} not found"); + + // Update workout plan properties + if (data.ContainsKey("name") && !string.IsNullOrEmpty(data["name"].GetString())) + existingWorkoutPlan.WorkoutPlanName = data["name"].GetString()!; + + if (data.ContainsKey("description")) + existingWorkoutPlan.Description = data["description"].GetString(); + + if (data.ContainsKey("isTemplate")) + existingWorkoutPlan.IsTemplate = data["isTemplate"].GetBoolean(); + + if (data.ContainsKey("workoutCategoryId")) + existingWorkoutPlan.FKWorkoutCategoryId = Guid.Parse(data["workoutCategoryId"].GetString()!); + + await _repository.UpdateWorkoutPlanAsync(existingWorkoutPlan); + + // Handle workout items update if provided + if (data.ContainsKey("workoutItems") && data["workoutItems"].ValueKind == System.Text.Json.JsonValueKind.Array) + { + // Get existing workout items for this plan + var existingWorkoutItems = await _repository.GetWorkoutItemsByWorkoutPlanIdAsync(workoutPlanId); + + // Delete all existing workout items (clean slate approach) + foreach (var existingItem in existingWorkoutItems) + { + await _repository.DeleteWorkoutItemAsync(existingItem.WorkoutItemId); + } + + // Create new workout items from payload + var workoutItemsArray = data["workoutItems"].EnumerateArray(); + foreach (var workoutItemData in workoutItemsArray) + { + // Only create workout item if exerciseDay is provided + if (workoutItemData.TryGetProperty("exerciseDay", out var nameElement) && + !string.IsNullOrEmpty(nameElement.GetString())) + { + string? exercisesJson = null; + if (workoutItemData.TryGetProperty("exercises", out var exercisesElement)) + { + if (exercisesElement.ValueKind == System.Text.Json.JsonValueKind.Array || + exercisesElement.ValueKind == System.Text.Json.JsonValueKind.Object) + { + exercisesJson = exercisesElement.GetRawText(); + } + else if (exercisesElement.ValueKind == System.Text.Json.JsonValueKind.String) + { + exercisesJson = exercisesElement.GetString(); + } + } + + var workoutItem = new WorkoutItem + { + ExerciseDay = nameElement.GetString()!, + Description = workoutItemData.TryGetProperty("description", out var desc) ? desc.GetString() : null, + Exercises = exercisesJson, + HowToDo = GetOptionalJsonText(workoutItemData, "howToDo"), + DoURL = GetOptionalJsonText(workoutItemData, "doURL", "videoUrl", "videoURL"), + Duration = GetOptionalJsonText(workoutItemData, "duration"), + WormUp = GetOptionalJsonText(workoutItemData, "wormUp", "warmUp", "warmup"), + Cooldown = GetOptionalJsonText(workoutItemData, "cooldown", "coolDown"), + FKWorkoutPlanId = workoutPlanId, + CreateDate = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + workoutItem.WorkoutExercises = ParseWorkoutExercises(workoutItemData); + + await _repository.CreateWorkoutItemAsync(workoutItem); + } + } + } + + await _uow.CommitAsync(); + + try + { + await _activityLogService.LogActivity( + new { description = $"Workout plan updated with ID {workoutPlanId}" }, + httpContext + ); + } + catch (Exception logEx) + { + Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}"); + } + + var updatedWorkoutPlan = await _repository.GetWorkoutPlanByIdAsync(workoutPlanId); + + if (updatedWorkoutPlan == null) + throw new KeyNotFoundException($"Workout plan with ID {workoutPlanId} not found after update"); + + // Transform workout items to have exercises as objects instead of JSON strings + var transformedWorkoutItems = updatedWorkoutPlan.WorkoutItems?.Select(item => + { + object? exercisesObj = null; + if (!string.IsNullOrEmpty(item.Exercises)) + { + try + { + exercisesObj = System.Text.Json.JsonSerializer.Deserialize(item.Exercises); + } + catch + { + exercisesObj = item.Exercises; // Keep as string if parsing fails + } + } + + return new + { + item.WorkoutItemId, + item.ExerciseDay, + item.Description, + Exercises = exercisesObj, + item.WorkoutExercises, + item.TotalExercises, + item.HowToDo, + item.DoURL, + item.Duration, + item.WormUp, + item.Cooldown, + item.WorkoutPlanId, + item.IsActive, + item.CreateDate, + item.UpdatedAt + }; + }).ToList(); + + return new + { + updatedWorkoutPlan.WorkoutPlanId, + updatedWorkoutPlan.Name, + updatedWorkoutPlan.Description, + updatedWorkoutPlan.IsTemplate, + updatedWorkoutPlan.WorkoutCategoryId, + updatedWorkoutPlan.WorkoutCategoryName, + updatedWorkoutPlan.ClientId, + updatedWorkoutPlan.ClientName, + updatedWorkoutPlan.IsActive, + updatedWorkoutPlan.CreateDate, + WorkoutItems = transformedWorkoutItems, + AssignedCustomers = updatedWorkoutPlan.AssignedCustomers + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task DeleteWorkoutPlan(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + if (!data.ContainsKey("workoutPlanId")) + { + throw new KeyNotFoundException("workoutPlanId is required"); + } + + Guid workoutPlanId = Guid.Parse(data["workoutPlanId"].GetString()!); + + var result = await _repository.DeleteWorkoutPlanAsync(workoutPlanId); + + if (!result) + { + throw new KeyNotFoundException("Workout plan not found"); + } + + await _uow.CommitAsync(); + + try + { + await _activityLogService.LogActivity( + new { description = $"Workout plan deleted with ID {workoutPlanId}" }, + httpContext + ); + } + catch (Exception logEx) + { + Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}"); + } + + return new { success = true, message = "Workout plan deleted successfully" }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + // Workout Item operations + public async Task GetWorkoutItemsByWorkoutPlanId(object payload, HttpContext httpContext) + { + try + { + var data = DeserializePayload.Deserialize(payload); + Guid workoutPlanId = Guid.Parse(data["workoutPlanId"].GetString()!); + + var workoutItems = await _repository.GetWorkoutItemsByWorkoutPlanIdAsync(workoutPlanId); + + // Transform workout items to have exercises as objects instead of JSON strings + var transformedWorkoutItems = workoutItems.Select(item => + { + object? exercisesObj = null; + if (!string.IsNullOrEmpty(item.Exercises)) + { + try + { + exercisesObj = System.Text.Json.JsonSerializer.Deserialize(item.Exercises); + } + catch + { + exercisesObj = item.Exercises; // Keep as string if parsing fails + } + } + + return new + { + item.WorkoutItemId, + item.ExerciseDay, + item.Description, + Exercises = exercisesObj, + item.WorkoutExercises, + item.TotalExercises, + item.HowToDo, + item.DoURL, + item.Duration, + item.WormUp, + item.Cooldown, + item.WorkoutPlanId, + item.IsActive, + item.CreateDate, + item.UpdatedAt + }; + }).ToList(); + + return transformedWorkoutItems; + } + catch + { + throw; + } + } + + public async Task CreateWorkoutItem(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + + // Handle exercises JSON + string? exercisesJson = null; + if (data.ContainsKey("exercises")) + { + var exercisesElement = data["exercises"]; + if (exercisesElement.ValueKind == System.Text.Json.JsonValueKind.Array || + exercisesElement.ValueKind == System.Text.Json.JsonValueKind.Object) + { + exercisesJson = exercisesElement.GetRawText(); + } + else if (exercisesElement.ValueKind == System.Text.Json.JsonValueKind.String) + { + exercisesJson = exercisesElement.GetString(); + } + } + + var workoutItem = new WorkoutItem + { + ExerciseDay = data["exerciseDay"].GetString()!, + Description = data.ContainsKey("description") ? data["description"].GetString() : null, + Exercises = exercisesJson, + HowToDo = GetOptionalJsonText(data, "howToDo"), + DoURL = GetOptionalJsonText(data, "doURL", "videoUrl", "videoURL"), + Duration = GetOptionalJsonText(data, "duration"), + WormUp = GetOptionalJsonText(data, "wormUp", "warmUp", "warmup"), + Cooldown = GetOptionalJsonText(data, "cooldown", "coolDown"), + FKWorkoutPlanId = Guid.Parse(data["workoutPlanId"].GetString()!), + CreateDate = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + workoutItem.WorkoutExercises = ParseWorkoutExercises(data); + + await _repository.CreateWorkoutItemAsync(workoutItem); + await _uow.CommitAsync(); + + var structuredExercises = workoutItem.WorkoutExercises + .OrderBy(we => we.SortOrder) + .Select(we => new + { + we.WorkoutExerciseId, + we.Name, + we.MuscleGroup, + we.Sets, + we.Reps, + we.Description, + we.BreathIn, + we.BreathOut, + we.VideoUrl, + we.SortOrder + }) + .ToList(); + + // Parse legacy Exercises JSON if present (kept for backward compatibility) + if (!string.IsNullOrEmpty(workoutItem.Exercises)) + { + try + { + var exercisesObj = System.Text.Json.JsonSerializer.Deserialize(workoutItem.Exercises); + return new + { + workoutItem.WorkoutItemId, + workoutItem.ExerciseDay, + workoutItem.Description, + Exercises = exercisesObj, + WorkoutExercises = structuredExercises, + workoutItem.HowToDo, + workoutItem.DoURL, + workoutItem.FKWorkoutPlanId, + workoutItem.IsActive, + workoutItem.CreateDate, + workoutItem.UpdatedAt + }; + } + catch + { + // If parsing fails, return as-is + } + } + + try + { + await _activityLogService.LogActivity(new { description = $"Workout item created with ID {workoutItem.WorkoutItemId}" }, httpContext); + } + catch (Exception logEx) + { + Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}"); + } + + return new + { + workoutItem.WorkoutItemId, + workoutItem.ExerciseDay, + workoutItem.Description, + workoutItem.Exercises, + WorkoutExercises = structuredExercises, + workoutItem.HowToDo, + workoutItem.DoURL, + workoutItem.Duration, + workoutItem.WormUp, + workoutItem.Cooldown, + workoutItem.FKWorkoutPlanId, + workoutItem.IsActive, + workoutItem.CreateDate, + workoutItem.UpdatedAt + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task AssignWorkoutToCustomer(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + Guid workoutPlanId = Guid.Parse(data["workoutPlanId"].GetString()!); + Guid customerId = Guid.Parse(data["customerId"].GetString()!); + + var customerWorkout = new CustomerWorkout + { + FKCustomerId = customerId, + FKWorkoutPlanId = workoutPlanId, + AssignedDate = DateTime.UtcNow, + Notes = data.ContainsKey("notes") ? data["notes"].GetString() : null, + IsActive = true + }; + + await _repository.AssignWorkoutToCustomerAsync(customerWorkout); + await _uow.CommitAsync(); + + try + { + await _activityLogService.LogActivity( + new { description = $"Workout plan {workoutPlanId} assigned to customer {customerId}" }, + httpContext + ); + } + catch (Exception logEx) + { + Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}"); + } + + try + { + var plan = await _repository.GetWorkoutPlanEntityByIdAsync(workoutPlanId); + var planName = plan?.WorkoutPlanName ?? "a workout plan"; + await _pushNotificationService.NotifyCustomerAsync( + customerId, + "New workout plan assigned", + $"Your coach assigned you \"{planName}\". Tap to start.", + "WorkoutPlanAssigned", + "WorkoutFollowedDetail", + new { workoutPlanId } + ); + } + catch (Exception notifyEx) + { + Console.WriteLine($"Push notification failed (non-critical): {notifyEx.Message}"); + } + + return new + { + customerWorkout.CustomerWorkoutId, + customerWorkout.FKCustomerId, + customerWorkout.FKWorkoutPlanId, + customerWorkout.AssignedDate, + customerWorkout.Notes, + customerWorkout.IsActive + }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task UnassignWorkoutFromCustomer(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + Guid customerWorkoutId = Guid.Parse(data["customerWorkoutId"].GetString()!); + + var result = await _repository.UnassignWorkoutFromCustomerAsync(customerWorkoutId); + + if (!result) + { + throw new NotFoundException($"Customer workout assignment with ID {customerWorkoutId} not found"); + } + + await _uow.CommitAsync(); + + try + { + await _activityLogService.LogActivity( + new { description = $"Workout assignment {customerWorkoutId} removed" }, + httpContext + ); + } + catch (Exception logEx) + { + Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}"); + } + + return new { success = true, message = "Workout unassigned from customer successfully" }; + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + } +} diff --git a/TenantDbContext.cs b/TenantDbContext.cs new file mode 100644 index 0000000..0b492e4 --- /dev/null +++ b/TenantDbContext.cs @@ -0,0 +1,298 @@ +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Fitness_core_BE +{ + // Per-tenant database: one gym's own operational data (staff, memberships, invoices, + // access control, attendance, ...). One instance of this context per resolved TenantId + // -- see Infra/Tenancy/TenantConnectionResolver.cs. Row-split entities (MealPlan/ + // WorkoutPlan family, PaymentTransactions) hold this tenant's own client-authored plans + // / member payments here -- CentralDbContext holds the same entity types for admin + // templates / platform-billing rows. + public class TenantDbContext : DbContext + { + public TenantDbContext(DbContextOptions options) : base(options) { } + + // The local anchor every tenant-scoped Customer-referencing table FKs to instead of + // the central Customer.CusId directly (EF/MySQL can't enforce a FK across two + // separate connections). + public DbSet TenantCustomerLink => Set(); + + // Staff & RBAC + public DbSet Staff => Set(); + public DbSet StaffPayRates => Set(); + public DbSet StaffPayments => Set(); + public DbSet GymRoles => Set(); + public DbSet GymRolePermissions => Set(); + + // Memberships + public DbSet CustomerPlans => Set(); + public DbSet CustomerSubscriptions => Set(); + public DbSet SubscriptionMembers => Set(); + public DbSet SubscriptionInstallments => Set(); + public DbSet CustomerSubItems => Set(); + public DbSet CustomerInvoices => Set(); + public DbSet CustomerInvoiceItems => Set(); + public DbSet CustomerCards => Set(); + // Row-split: only rows with FK_CustomerInvoiceId set (member payments) live here -- + // platform billing payments (FK_ClientInvoiceId set) live in CentralDbContext instead. + public DbSet PaymentTransactions => Set(); + public DbSet LedgerEntries => Set(); + public DbSet ClientTerms => Set(); + public DbSet CustomerTermsAcceptances => Set(); + + // Access control & devices + public DbSet AccessDevices => Set(); + public DbSet AccessDeviceBindings => Set(); + public DbSet AccessProfiles => Set(); + public DbSet UnlinkedDeviceEnrollments => Set(); + public DbSet CustomerAccess => Set(); + // Agent/PairingCode moved to CentralDbContext -- see its DbSet comment. FKAgentId on + // AccessDevice below stays a plain scalar column (unenforced cross-schema reference). + public DbSet DeviceCommands => Set(); + public DbSet Attendances => Set(); + + // Client-authored (non-template) meal/workout plans + assignment join tables + public DbSet MealPlans => Set(); + public DbSet Meal => Set(); + public DbSet CustomerMeals => Set(); + public DbSet WorkoutPlans => Set(); + public DbSet WorkoutItems => Set(); + public DbSet WorkoutExercises => Set(); + public DbSet CustomerWorkouts => Set(); + public DbSet CustomerWorkoutExerciseCompletions => Set(); + + // Multi-facility session booking + RFID time-bank cards (pool/court/class booking, one + // per club since single-tenant -- see Models/Facility.cs and friends). + public DbSet Facilities => Set(); + public DbSet FacilitySessions => Set(); + public DbSet Bookings => Set(); + public DbSet TimeBankCards => Set(); + public DbSet TimeBankCardUsageLogs => Set(); + + // Physical access zones (Pool Area, Indoor Gym, ...) -- see Models/Place.cs. + public DbSet Places => Set(); + public DbSet CustomerPlanPlaces => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Central-only entity types must never be part of this model at all. Ignoring + // individual navigations (below) isn't enough on its own: an unignored central + // collection nav elsewhere (e.g. Customer.CustomerSubscriptions/.CustomerMeals/ + // .CustomerWorkouts, Client.ClientBusinesses) pulls the whole + // type into this context's model via EF's reachability-based discovery, which then + // invents a *second*, bogus shadow-FK relationship (e.g. a phantom + // "CustomerCusId" column) between that type and whatever tenant entity also + // happens to have its own real FK to it (e.g. CustomerSubscription, CustomerMeal, + // CustomerWorkout via TenantCustomerLink) -- breaking every query against that + // tenant entity with "Unknown column" once EF tries to select the phantom column. + // Blanket-ignoring the type at the root closes every such path at once, including + // ones not yet hit by a specific per-navigation Ignore below. + modelBuilder.Ignore(); + modelBuilder.Ignore(); + // These were previously missing from this list. On separate physical databases + // (GYM's production layout) the gap was harmless -- the schema was hand-maintained/ + // EnsureCreated, so these reachability-discovered phantom entities never actually + // got materialized as tables. Generating a real migration from this context's model + // (both contexts now share one DB) surfaced it immediately: each of these got its + // own duplicate table (usually singular-named, since they have no explicit DbSet + // here) alongside CentralDbContext's real, correctly-named one. Ignoring them here + // closes every reachability path at once, same as Customer/Client/WorkoutCategory + // above. + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + // Plan categories are admin-authored and live centrally (CentralDbContext owns + // and seeds them; WorkoutRepository/MealRepository read and write them via + // _central). Left un-ignored, WorkoutPlan.WorkoutCategory / MealPlan.MealCategory + // drag the type into this model, which materialises an empty tenant-side + // "workoutcategory"/"mealcategory" table AND a real FK from the plan table to it + // -- so creating a gym's own plan fails the constraint against a table that is + // never populated. + modelBuilder.Ignore(); + modelBuilder.Ignore(); + // Agent/PairingCode now live only in CentralDbContext (see its DbSet comment). + modelBuilder.Ignore(); + modelBuilder.Ignore(); + + // Central-pointing navigation properties this context can't resolve -- the FK + // scalar column stays (it's still real data, just not a same-context relationship + // once central/tenant are physically separate DBs). + modelBuilder.Entity().Ignore(a => a.Client); + modelBuilder.Entity().Ignore(a => a.User); + modelBuilder.Entity().Ignore(p => p.Client); + modelBuilder.Entity().Ignore(p => p.User); + modelBuilder.Entity().Ignore(d => d.Client); + modelBuilder.Entity().Ignore(c => c.Customer); + // A followed/assigned plan can be an admin-authored template (central) or this + // gym's own plan (tenant) -- GetWorkoutPlanListAsync serves customers the central + // templates. An enforced FK to the tenant plan table therefore can't hold: it + // rejects every follow of a template. The scalar id stays; which bucket it points + // at is resolved in the repository, the same way TenantCustomerLink ids are. + modelBuilder.Entity().Ignore(cw => cw.WorkoutPlan); + modelBuilder.Entity().Ignore(cm => cm.MealPlan); + modelBuilder.Entity().Ignore(c => c.Client); + modelBuilder.Entity().Ignore(i => i.Client); + modelBuilder.Entity().Ignore(i => i.User); + modelBuilder.Entity().Ignore(p => p.Client); + modelBuilder.Entity().Ignore(t => t.Client); + modelBuilder.Entity().Ignore(l => l.Client); + modelBuilder.Entity().Ignore(r => r.Client); + modelBuilder.Entity().Ignore(s => s.User); + modelBuilder.Entity().Ignore(s => s.Client); + modelBuilder.Entity().Ignore(p => p.Client); + modelBuilder.Entity().Ignore(p => p.Client); + modelBuilder.Entity().Ignore(p => p.Client); + modelBuilder.Entity().Ignore(p => p.Customer); + modelBuilder.Entity().Ignore(p => p.ClientInvoice); + + // Row-split entities: real DbSets in both contexts (see class comment), but on one + // shared database only one context's migration should physically create the table. + // CentralDbContext owns creation here (it also seeds admin-authored templates into + // MealPlan/WorkoutPlan* via HasData) -- ExcludeFromMigrations lets this context keep + // querying/writing these tables normally without generating a conflicting duplicate + // CreateTable for them. + modelBuilder.Entity().ToTable(tb => tb.ExcludeFromMigrations()); + modelBuilder.Entity().ToTable(tb => tb.ExcludeFromMigrations()); + modelBuilder.Entity().ToTable(tb => tb.ExcludeFromMigrations()); + modelBuilder.Entity().ToTable(tb => tb.ExcludeFromMigrations()); + modelBuilder.Entity().ToTable(tb => tb.ExcludeFromMigrations()); + modelBuilder.Entity().ToTable(tb => tb.ExcludeFromMigrations()); + + // Central-pointing scalar FKs on the new booking entities -- same pattern as every + // other .Client nav in this context (FKClientId column stays, real relationship + // doesn't, since Client lives only in CentralDbContext). + modelBuilder.Entity().Ignore(f => f.Client); + modelBuilder.Entity().Ignore(s => s.Client); + modelBuilder.Entity().Ignore(b => b.Client); + modelBuilder.Entity().Ignore(t => t.Client); + modelBuilder.Entity().Ignore(l => l.Client); + modelBuilder.Entity().Ignore(p => p.Client); + + // A session's capacity is enforced by counting non-cancelled bookings against it at + // booking time, not by a unique index -- multiple guests/members can share one slot + // up to Facility.DefaultCapacity. What must stay unique is a member double-booking + // the same session twice. + modelBuilder.Entity() + .HasIndex(b => new { b.FKFacilitySessionId, b.FKCustomerId }) + .IsUnique() + .HasFilter("`FKCustomerId` IS NOT NULL AND `Status` <> 'Cancelled'"); + + modelBuilder.Entity() + .HasIndex(s => new { s.FKFacilityId, s.StartTime }); + + modelBuilder.Entity() + .HasIndex(t => t.RfidTagId) + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(b => b.OtpCode); + + // A customer can appear only once per subscription; installments are unique per position. + modelBuilder.Entity() + .HasIndex(sm => new { sm.FKSubscriptionId, sm.FKCustomerId }) + .IsUnique(); + modelBuilder.Entity() + .HasIndex(si => new { si.FKSubscriptionId, si.InstallmentNo }) + .IsUnique(); + + // An exercise can only be marked completed once per customer workout. + modelBuilder.Entity() + .HasIndex(c => new { c.FKCustomerWorkoutId, c.FKWorkoutExerciseId }) + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(p => new { p.FKClientId, p.FKUserId }) + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(b => new { b.FKAccessProfileId, b.FKDeviceId }) + .IsUnique(); + modelBuilder.Entity() + .HasIndex(b => new { b.FKDeviceId, b.DeviceEmployeeNo }) + .IsUnique(); + modelBuilder.Entity() + .HasOne(b => b.AccessProfile).WithMany(p => p.DeviceBindings) + .HasForeignKey(b => b.FKAccessProfileId).OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasOne(b => b.AccessDevice).WithMany() + .HasForeignKey(b => b.FKDeviceId).OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasIndex(d => new { d.FKClientId, d.HardwareId }) + .IsUnique(); + // Agent now lives centrally -- FKAgentId stays a real column, just not a + // same-context FK anymore (same pattern as every other central-pointing scalar). + modelBuilder.Entity().Ignore(d => d.Agent); + + // One device per place for now -- see AccessDevice.FKPlaceId comment. Lifting + // this to many-devices-per-place later is just dropping this index. + modelBuilder.Entity() + .HasIndex(d => d.FKPlaceId) + .IsUnique() + .HasFilter("`FKPlaceId` IS NOT NULL"); + + modelBuilder.Entity() + .HasIndex(pp => new { pp.FKCustomerPlanId, pp.FKPlaceId }) + .IsUnique(); + modelBuilder.Entity() + .HasOne(pp => pp.CustomerPlan).WithMany() + .HasForeignKey(pp => pp.FKCustomerPlanId).OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasOne(pp => pp.Place).WithMany() + .HasForeignKey(pp => pp.FKPlaceId).OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasIndex(e => new { e.FKDeviceId, e.DeviceEmployeeNo, e.LinkedAt }); + + modelBuilder.Entity() + .HasIndex(a => a.SourceEventKey) + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(c => new { c.FKClientId, c.Status, c.NextAttemptAt }); + modelBuilder.Entity() + .Property(c => c.MaxAttempts) + .HasDefaultValue(5); + + modelBuilder.Entity() + .HasKey(p => new { p.FKGymRoleId, p.NavItemKey }); + modelBuilder.Entity().Ignore(p => p.NavItem); + } + } + + public class TenantDbContextFactory : IDesignTimeDbContextFactory + { + public TenantDbContext CreateDbContext(string[] args) + { + var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development"; + var config = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false) + .AddJsonFile($"appsettings.{env}.json", optional: true) + .Build(); + + var optionsBuilder = new DbContextOptionsBuilder(); + var connectionString = config.GetConnectionString("DefaultConnection"); + optionsBuilder.UseMySql(connectionString, new MySqlServerVersion(new Version(8, 0, 0))); + + return new TenantDbContext(optionsBuilder.Options); + } + } +} diff --git a/appsettings.Development.json b/appsettings.Development.json new file mode 100644 index 0000000..7e81058 --- /dev/null +++ b/appsettings.Development.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ConnectionStrings": { + "DefaultConnection": "server=localhost;port=3306;database=vijaya_core;user=root;password=" + }, + "Jwt": { + "PublicKey": "wO62C07ky/HpJG60atCSddBjSMqY3ZUyYUk9WkhXRwWBDdw1GhyuqgjCZVSyKs7ZRsPUhSEaQsYE2qCAQURtMk2nVLy3HmNqPfSIBbA82nMq9oel1wdnAeXcJWc3V168FvzuRZu+JQS4OFT2OrNnMgdUDDB2nerLzbk71OP6WCqKvGY8YIfUr7pBbu6IWSq0Df498gE1oejsySAzlpSt8t39OhwJySm+d3atXo/JOyfQqnvVTIdNa47MrVztShElIXLLBqw4fuXy7UiyH1fjZSveaS98n673XTiCHUEZd1X7HbJq8B6bXa/7K2uL4MqofeyKZ61NnF8UnVXVnwP0wQ==AQAB", + "Audience": "VijayaClient", + "ExpiresInMinutes": "1000", + "Issuer": "VijayaAuth" + } +} diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..d3cd013 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,69 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ConnectionStrings": { + "DefaultConnection": "server=;port=3306;database=vijaya_core;user=;password=" + }, + "AllowedHosts": "*", + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://0.0.0.0:5601" + } + }, + + "AllowedOrigins": [ "*" ] + }, + "Jwt": { + "PublicKey": "", + "Audience": "VijayaClient", + "ExpiresInMinutes": "1000", + "Issuer": "VijayaAuth" + }, + "Cors": { + "AllowedOrigins": [ "*" ], + "AllowedMethods": [ "GET", "POST", "PUT", "DELETE" ], + "AllowedHeaders": [ "*" ] + }, + "Gemini": { + "ApiKey": "" + }, + "AuthService": { + "BaseUrl": "http://127.0.0.1:5602" + }, + "PaymentService": { + "BaseUrl": "http://127.0.0.1:5120", + "InternalApiKey": "" + }, + "Worker": { + "InternalApiKey": "" + }, + "RequestAccessToken": { + "SigningKey": "", + "ExpiresInMinutes": 15 + }, + "InvoicePaymentToken": { + "SigningKey": "", + "ExpiresInDays": 7 + }, + "Sms": { + "ApiUrl": "", + "ApiKey": "" + }, + "Gmail": { + "Host": "smtp.gmail.com", + "Port": 587, + "FromName": "Vijaya Sport Club", + "User": "", + "AppPassword": "" + }, + "App": { + "FrontendBaseUrl": "", + "MobileAppLink": "/app" + } + +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a551dfe --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "Fitness_Core_BE", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/postman/Client-Admin-API.postman_collection.json b/postman/Client-Admin-API.postman_collection.json new file mode 100644 index 0000000..3fb61cf --- /dev/null +++ b/postman/Client-Admin-API.postman_collection.json @@ -0,0 +1,389 @@ +{ + "info": { + "name": "Hexa Fitness - Client & Admin API", + "description": "Client onboarding funnel (Request Access -> Sandbox -> Activation -> Payments Enablement), the Client's own dashboard, and the Admin console. See CLIENT_ADMIN_API_GUIDE.md in the repo root for auth details.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { "key": "baseUrl", "value": "http://localhost:5601" }, + { "key": "mobileNumber", "value": "+94770000000" }, + { "key": "otpReferenceNumber", "value": "" }, + { "key": "otpCode", "value": "123456" }, + { "key": "requestAccessToken", "value": "" }, + { "key": "onboardingRequestId", "value": "" }, + { "key": "sandboxToken", "value": "" }, + { "key": "activationToken", "value": "" }, + { "key": "mobileAuthToken", "value": "", "description": "AuthHex-issued JWT for a logged-in Client/Admin; sent as the MobileAuthToken header consumed by [CustomMiddleware]" }, + { "key": "clientId", "value": "" }, + { "key": "customerId", "value": "" }, + { "key": "staffId", "value": "" }, + { "key": "customerInvoiceId", "value": "" } + ], + "auth": { + "type": "apikey", + "apikey": [ + { "key": "key", "value": "MobileAuthToken", "type": "string" }, + { "key": "value", "value": "{{mobileAuthToken}}", "type": "string" }, + { "key": "in", "value": "header", "type": "string" } + ] + }, + "item": [ + { + "name": "Client - Onboarding (Request Access)", + "item": [ + { + "name": "Send Mobile OTP", + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "url": "{{baseUrl}}/api/client/onboarding/requests/otp", + "body": { + "mode": "raw", + "raw": "{\n \"mobileNumber\": \"{{mobileNumber}}\"\n}" + }, + "auth": { "type": "noauth" } + } + }, + { + "name": "Verify Mobile OTP", + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "url": "{{baseUrl}}/api/client/onboarding/requests/otp/verify", + "body": { + "mode": "raw", + "raw": "{\n \"mobileNumber\": \"{{mobileNumber}}\",\n \"referenceNumber\": \"{{otpReferenceNumber}}\",\n \"otpCode\": \"{{otpCode}}\"\n}" + }, + "auth": { "type": "noauth" }, + "description": "mobileNumber must match the number OTP was sent to in the previous step. Response.data.requestAccessToken -> save into the {{requestAccessToken}} collection variable." + } + }, + { + "name": "Create Onboarding Request", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Authorization", "value": "Bearer {{requestAccessToken}}" } + ], + "url": "{{baseUrl}}/api/client/onboarding/requests", + "body": { + "mode": "raw", + "raw": "{\n \"ownerName\": \"Ashan Perera\",\n \"gymName\": \"IronCore Fitness\",\n \"city\": \"Colombo\"\n}" + }, + "auth": { "type": "noauth" }, + "description": "Response.data.onboardingRequestId -> {{onboardingRequestId}} (also doubles as {{activationToken}} once approved). Response.data.sandboxToken -> {{sandboxToken}}." + } + }, + { + "name": "Get Sandbox Status", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/client/onboarding/sandbox/{{sandboxToken}}/status", + "auth": { "type": "noauth" } + } + }, + { + "name": "Update Sandbox Progress", + "request": { + "method": "PATCH", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "url": "{{baseUrl}}/api/client/onboarding/sandbox/{{sandboxToken}}/progress", + "body": { + "mode": "raw", + "raw": "{\n \"tourStepCompleted\": \"added_first_customer\",\n \"minutesElapsedDelta\": 2\n}" + }, + "auth": { "type": "noauth" } + } + } + ] + }, + { + "name": "Client - Activation", + "item": [ + { + "name": "Send Activation Email OTP", + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "url": "{{baseUrl}}/api/client/activation/{{activationToken}}/email/otp", + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"owner@ironcore.gym\"\n}" + }, + "auth": { "type": "noauth" }, + "description": "activationToken = OnboardingRequest.Id, only valid once Admin has set status to Approved." + } + }, + { + "name": "Verify Activation Email OTP", + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "url": "{{baseUrl}}/api/client/activation/{{activationToken}}/email/otp/verify", + "body": { + "mode": "raw", + "raw": "{\n \"referenceNumber\": \"{{otpReferenceNumber}}\",\n \"otpCode\": \"{{otpCode}}\"\n}" + }, + "auth": { "type": "noauth" } + } + }, + { + "name": "Upload NIC", + "request": { + "method": "POST", + "url": "{{baseUrl}}/api/client/activation/{{activationToken}}/nic", + "body": { + "mode": "formdata", + "formdata": [ + { "key": "nicNumber", "value": "902345678V", "type": "text" }, + { "key": "frontImage", "type": "file", "src": [] }, + { "key": "backImage", "type": "file", "src": [] } + ] + }, + "auth": { "type": "noauth" } + } + }, + { + "name": "Update Gym Profile", + "request": { + "method": "PUT", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "url": "{{baseUrl}}/api/client/activation/{{activationToken}}/gym-profile", + "body": { + "mode": "raw", + "raw": "{\n \"gymName\": \"IronCore Fitness\",\n \"address\": \"123 Galle Road\",\n \"city\": \"Colombo\"\n}" + }, + "auth": { "type": "noauth" } + } + }, + { + "name": "Set Payment Method", + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "url": "{{baseUrl}}/api/client/activation/{{activationToken}}/payment-method", + "body": { + "mode": "raw", + "raw": "{\n \"payHereToken\": \"tok_test_4242424242424242\"\n}" + }, + "auth": { "type": "noauth" }, + "description": "payHereToken comes from PayHere.js tokenizing the card client-side; this is opaque staging data server-side." + } + }, + { + "name": "Charge & Activate", + "request": { + "method": "POST", + "url": "{{baseUrl}}/api/client/activation/{{activationToken}}/charge", + "auth": { "type": "noauth" }, + "description": "Creates real User/Client/ClientBusiness/ClientCard/ClientWallet rows and registers the Client with AuthHex. Response.data.accessToken/refreshToken are the caller's new session tokens; save accessToken into {{mobileAuthToken}} for the authenticated requests below." + } + }, + { + "name": "Get Activation Checklist", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/client/activation/{{activationToken}}/checklist", + "auth": { "type": "noauth" } + } + } + ] + }, + { + "name": "Client - Own Profile & Business", + "item": [ + { "name": "Get My Profile", "request": { "method": "GET", "url": "{{baseUrl}}/api/client/me" } }, + { "name": "Get Packages", "request": { "method": "GET", "url": "{{baseUrl}}/api/client/packages" } } + ] + }, + { + "name": "Client - Customers", + "item": [ + { "name": "Get Customers", "request": { "method": "GET", "url": "{{baseUrl}}/api/client/customers" } }, + { "name": "Get Customer By Id", "request": { "method": "GET", "url": "{{baseUrl}}/api/client/customers/{{customerId}}" } }, + { + "name": "Create Customer", + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "url": "{{baseUrl}}/api/client/customers", + "body": { + "mode": "raw", + "raw": "{\n \"fullname\": \"Nimal Silva\",\n \"userName\": \"nimal.silva\",\n \"email\": \"nimal@example.com\",\n \"phoneNumber\": \"+94771234567\",\n \"nic\": \"985432109V\",\n \"gender\": \"Male\",\n \"birthday\": \"1998-05-12\",\n \"workoutPlanIds\": [],\n \"mealPlanIds\": [],\n \"customerPlanId\": null\n}" + } + } + }, + { + "name": "Update Customer", + "request": { + "method": "PUT", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "url": "{{baseUrl}}/api/client/customers/{{customerId}}", + "body": { + "mode": "raw", + "raw": "{\n \"fullname\": \"Nimal Silva\",\n \"userName\": \"nimal.silva\",\n \"email\": \"nimal@example.com\",\n \"phoneNumber\": \"+94771234567\",\n \"nic\": \"985432109V\",\n \"gender\": \"Male\",\n \"birthday\": \"1998-05-12\"\n}" + } + } + }, + { "name": "Delete Customer", "request": { "method": "DELETE", "url": "{{baseUrl}}/api/client/customers/{{customerId}}" } } + ] + }, + { + "name": "Client - Staff", + "item": [ + { "name": "Get Staff (own client)", "request": { "method": "GET", "url": "{{baseUrl}}/api/client/staff" } }, + { "name": "Get Staff By Id", "request": { "method": "GET", "url": "{{baseUrl}}/api/client/staff/{{staffId}}" } }, + { + "name": "Create Staff", + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "url": "{{baseUrl}}/api/client/staff", + "body": { + "mode": "raw", + "raw": "{\n \"userName\": \"kasun.trainer\",\n \"email\": \"kasun@ironcore.gym\",\n \"phoneNumber\": \"+94779876543\",\n \"staffPosition\": \"Trainer\",\n \"department\": \"Fitness Floor\",\n \"hireDate\": \"2026-07-01T00:00:00Z\"\n}" + } + } + }, + { + "name": "Update Staff", + "request": { + "method": "PUT", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "url": "{{baseUrl}}/api/client/staff/{{staffId}}", + "body": { + "mode": "raw", + "raw": "{\n \"userName\": \"kasun.trainer\",\n \"email\": \"kasun@ironcore.gym\",\n \"phoneNumber\": \"+94779876543\",\n \"staffPosition\": \"Senior Trainer\",\n \"department\": \"Fitness Floor\"\n}" + } + } + }, + { "name": "Delete Staff (soft)", "request": { "method": "DELETE", "url": "{{baseUrl}}/api/client/staff/{{staffId}}?softDelete=true" } } + ] + }, + { + "name": "Client - Customer Invoices", + "item": [ + { "name": "Get Customer Invoices", "request": { "method": "GET", "url": "{{baseUrl}}/api/client/customer-invoices" } }, + { "name": "Get Customer Invoice By Id", "request": { "method": "GET", "url": "{{baseUrl}}/api/client/customer-invoices/{{customerInvoiceId}}" } } + ] + }, + { + "name": "Client - Dashboard Stats", + "item": [ + { "name": "Get Stat Summary", "request": { "method": "GET", "url": "{{baseUrl}}/api/client/stats/summary" } }, + { "name": "Get Monthly Revenue", "request": { "method": "GET", "url": "{{baseUrl}}/api/client/stats/monthly-revenue" } }, + { "name": "Get Recent Payments", "request": { "method": "GET", "url": "{{baseUrl}}/api/client/stats/recent-payments" } } + ] + }, + { + "name": "Client - Payments Enablement", + "item": [ + { "name": "Get Payment Tracks", "request": { "method": "GET", "url": "{{baseUrl}}/api/client/payments/tracks" } }, + { + "name": "Select Payment Track", + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "url": "{{baseUrl}}/api/client/payments/track-selection", + "body": { + "mode": "raw", + "raw": "{\n \"track\": \"HexaFitness\"\n}" + } + } + }, + { + "name": "Submit Payout KYC", + "request": { + "method": "POST", + "url": "{{baseUrl}}/api/client/payments/kyc", + "body": { + "mode": "formdata", + "formdata": [ + { "key": "brCertificate", "type": "file", "src": [] }, + { "key": "bankProof", "type": "file", "src": [] } + ] + } + } + }, + { "name": "Get KYC Status", "request": { "method": "GET", "url": "{{baseUrl}}/api/client/payments/kyc/status" } }, + { "name": "Get Wallet", "request": { "method": "GET", "url": "{{baseUrl}}/api/client/payments/wallet" } }, + { "name": "Get Payout Schedule", "request": { "method": "GET", "url": "{{baseUrl}}/api/client/payments/payout-schedule" } } + ] + }, + { + "name": "Admin - Onboarding Approval Queue", + "item": [ + { + "name": "List Onboarding Requests", + "request": { "method": "GET", "url": "{{baseUrl}}/api/admin/onboarding-requests?status=New&search=&page=1&pageSize=20" } + }, + { "name": "Get Onboarding Request By Id", "request": { "method": "GET", "url": "{{baseUrl}}/api/admin/onboarding-requests/{{onboardingRequestId}}" } }, + { + "name": "Update Onboarding Status (Called)", + "request": { + "method": "PATCH", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "url": "{{baseUrl}}/api/admin/onboarding-requests/{{onboardingRequestId}}/status", + "body": { "mode": "raw", "raw": "{\n \"status\": \"Called\"\n}" } + } + }, + { + "name": "Update Onboarding Status (Approved)", + "request": { + "method": "PATCH", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "url": "{{baseUrl}}/api/admin/onboarding-requests/{{onboardingRequestId}}/status", + "body": { "mode": "raw", "raw": "{\n \"status\": \"Approved\"\n}" }, + "description": "On success, onboardingRequestId also becomes a valid {{activationToken}} for the Client - Activation folder." + } + }, + { + "name": "Update Onboarding Status (Rejected)", + "request": { + "method": "PATCH", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "url": "{{baseUrl}}/api/admin/onboarding-requests/{{onboardingRequestId}}/status", + "body": { "mode": "raw", "raw": "{\n \"status\": \"Rejected\",\n \"reason\": \"Duplicate submission\"\n}" } + } + }, + { + "name": "Resend Activation Link", + "request": { "method": "POST", "url": "{{baseUrl}}/api/admin/onboarding-requests/{{onboardingRequestId}}/resend-activation-link" } + } + ] + }, + { + "name": "Admin - Payout KYC Review", + "item": [ + { "name": "List Payout KYC (Pending)", "request": { "method": "GET", "url": "{{baseUrl}}/api/admin/payout-kyc?status=Pending" } }, + { "name": "Get Payout KYC By Client Id", "request": { "method": "GET", "url": "{{baseUrl}}/api/admin/payout-kyc/{{clientId}}" } }, + { + "name": "Review Payout KYC (Approve)", + "request": { + "method": "PATCH", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "url": "{{baseUrl}}/api/admin/payout-kyc/{{clientId}}/status", + "body": { "mode": "raw", "raw": "{\n \"status\": \"Approved\"\n}" } + } + }, + { + "name": "Review Payout KYC (Reject)", + "request": { + "method": "PATCH", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "url": "{{baseUrl}}/api/admin/payout-kyc/{{clientId}}/status", + "body": { "mode": "raw", "raw": "{\n \"status\": \"Rejected\",\n \"reason\": \"Bank proof illegible\"\n}" } + } + } + ] + }, + { + "name": "Admin - Client Directory", + "item": [ + { "name": "List Clients", "request": { "method": "GET", "url": "{{baseUrl}}/api/admin/clients?search=&page=1&pageSize=20" } }, + { "name": "Get Client By Id", "request": { "method": "GET", "url": "{{baseUrl}}/api/admin/clients/{{clientId}}" } } + ] + } + ] +} diff --git a/postman/SubscriptionRenewalJob.postman_collection.json b/postman/SubscriptionRenewalJob.postman_collection.json new file mode 100644 index 0000000..89ad48b --- /dev/null +++ b/postman/SubscriptionRenewalJob.postman_collection.json @@ -0,0 +1,176 @@ +{ + "info": { + "name": "Hexa Fitness - Subscription Renewal Job (Manual Walkthrough)", + "description": "Walks through the daily SubscriptionRenewalJob approval-gated renewal flow end-to-end against your LOCAL database (hexa_tenant_test1 / Test Gym Alpha). Run folders top to bottom: 0 -> 1 -> 2 -> 3 -> 4. Run '0. Auth > Login' first -- it auto-saves the JWT into {{mobileAuthToken}}, which every other request sends as the 'MobileAuthToken: Bearer ' header (same auth JwtMiddleware accepts as a fallback for the httponly cookie).\n\nSeeded test data (already set up via real API calls, not raw SQL -- only RenewalDate was backdated directly in the DB since no endpoint exposes it):\n- Renewal Test Customer (sub 7d7351ed...) -> current invoice PENDING, 3000, RenewalDate backdated to 2026-09-13\n- Renewal Test Customer 2 (sub edbcf90e...) -> current invoice PAID, 6000, RenewalDate backdated to 2026-09-13\n- Renewal Test Customer 3 (sub 39feeb0b...) -> current invoice PENDING, 7500, RenewalDate backdated to 2026-09-13\n- Renewal Test Customer 4 (sub 995a4915...) -> current invoice PARTIAL,4000 (2000 paid / 2000 balance), RenewalDate backdated to 2026-09-13\n\nWhat to expect when you POST '2. Background Job > Trigger Renewal Job Now':\n- Customer/Customer 3 (Pending): old invoice flips to status RolledOver, a NEW invoice is created with IsActive=false, amount = new round + carried-forward unpaid balance (itemized as a separate 'Previous Balance' line).\n- Customer 2 (Paid): a NEW invoice is created with IsActive=false, amount = new round only (no carry-forward), old Paid invoice untouched.\n- Customer 4 (Partial): old invoice flips to RolledOver, NEW invoice carries the 2000 unpaid balance forward alongside the new round.\n- The response also iterates your OTHER local tenant schemas (hexa_tenant_ironcore and any unmigrated ones) -- those are expected to show a Failed/error entry since they don't have this migration applied. That is correct isolation behavior, not a bug.\n\nAfter the trigger, '3. Approvals' will list exactly these 4 newly-created unapproved invoices. Copy an invoiceId from that response into the {{invoiceId}} variable to try Approve / Change Plan / Reject on it. Approve and Change-Plan will make that invoice reappear (IsActive=true) in '4. Verify After > Get Member Invoices (scope=latest)'; Reject will deactivate the subscription instead.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { "key": "baseUrl", "value": "http://localhost:5601" }, + { "key": "identifier", "value": "testgym1@local.test" }, + { "key": "password", "value": "TestPass@123" }, + { "key": "mobileAuthToken", "value": "", "description": "Auto-filled by 0. Auth > Login's test script. Sent as the MobileAuthToken header." }, + { "key": "invoiceId", "value": "", "description": "Paste an unapproved invoice id here after running the trigger + 'List Unapproved Invoices'." }, + { "key": "customerId", "value": "08df125d-311f-41eb-8bea-aeddd5df1fc0", "description": "Renewal Test Customer (Pending scenario). Others: cust2=08df125d-7cab-494f-84fc-2bd895f37cb5 (Paid), cust3=08df125d-7ef7-4967-8d98-cfeef523dbef (Pending), cust4=08df125d-811d-4047-8542-263194ab0745 (Partial)." }, + { "key": "newCustomerPlanId", "value": "72aaff5a-7f2b-4de3-a2f7-996095596a38", "description": "'Renewal Test Plan B' (4500/mo) -- example target plan for the Change-Plan action." } + ], + "auth": { + "type": "apikey", + "apikey": [ + { "key": "key", "value": "MobileAuthToken", "type": "string" }, + { "key": "value", "value": "Bearer {{mobileAuthToken}}", "type": "string" }, + { "key": "in", "value": "header", "type": "string" } + ] + }, + "item": [ + { + "name": "0. Auth", + "item": [ + { + "name": "Login", + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "url": "{{baseUrl}}/api/auth", + "body": { + "mode": "raw", + "raw": "{\n \"functionName\": \"LoginUser\",\n \"payload\": {\n \"identifier\": \"{{identifier}}\",\n \"password\": \"{{password}}\"\n },\n \"reference\": \"postman-login\"\n}" + }, + "auth": { "type": "noauth" }, + "description": "Function-dispatch login (not a REST route). Response.data.accessToken is auto-saved into {{mobileAuthToken}} by the test script below." + }, + "response": [], + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var jsonData = pm.response.json();", + "if (jsonData && jsonData.data && jsonData.data.accessToken) {", + " pm.collectionVariables.set('mobileAuthToken', jsonData.data.accessToken);", + " console.log('Saved accessToken to {{mobileAuthToken}}');", + "} else {", + " console.warn('Login response did not contain data.accessToken', jsonData);", + "}" + ], + "type": "text/javascript" + } + } + ] + } + ] + }, + { + "name": "1. Setup Check (before trigger)", + "item": [ + { + "name": "List Renewal Approvals (expect empty)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/client/renewal-approvals", + "description": "Should return an empty array before the job has run -- nothing is pending approval yet." + } + }, + { + "name": "Get Member Invoices (scope=latest)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/client/member-invoices?scope=latest", + "description": "One row per customer, approved-only. Look for the 4 'Renewal Test Customer*' rows: Pending 3000, Paid 6000, Pending 7500, Partial 4000 (paid 2000 / balance 2000)." + } + }, + { + "name": "Get Invoice History For Customer", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/client/member-invoices/customer/{{customerId}}", + "description": "Full invoice history for one customer (the 'view previous invoices' action). Useful after the trigger to see the old invoice's status flip to RolledOver alongside the new unapproved one." + } + } + ] + }, + { + "name": "2. Background Job", + "item": [ + { + "name": "Trigger Renewal Job Now", + "request": { + "method": "POST", + "url": "{{baseUrl}}/api/admin/background-jobs/renewal/trigger", + "description": "Runs SubscriptionRenewalRunner immediately across every provisioned tenant schema (same code the 01:00 Sri Lanka time daily job calls) instead of waiting for the real 1am tick. Expect one result entry per tenant: hexa_tenant_test1 (Test Gym Alpha) should show renewed=4, failed=0; the other local tenant(s) are expected to show a Failed entry since they don't have the RenewalDate/IsActive migration applied yet -- that's correct per-tenant isolation, not a bug." + } + }, + { + "name": "Get Renewal Job Run History", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/admin/background-jobs/renewal/runs?take=20", + "description": "JobRunHistory entries for 'SubscriptionRenewalJob', most recent first -- confirms the manual trigger (and the real nightly run, once it fires) got recorded." + } + } + ] + }, + { + "name": "3. Approvals (after trigger)", + "item": [ + { + "name": "List Unapproved Invoices", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/client/renewal-approvals", + "description": "Should now list the 4 new IsActive=false invoices the trigger just created. Copy one customerInvoiceId into the {{invoiceId}} collection variable, then use Approve / Change Plan / Reject below." + } + }, + { + "name": "Approve", + "request": { + "method": "POST", + "url": "{{baseUrl}}/api/client/renewal-approvals/{{invoiceId}}/approve", + "description": "Sets invoice IsActive=true, builds a fresh installment schedule anchored to now, advances the subscription's RenewalDate by the plan's duration from its OLD RenewalDate. Re-run 'Get Member Invoices (scope=latest)' afterwards to see it reappear as the current invoice." + } + }, + { + "name": "Change Plan", + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "url": "{{baseUrl}}/api/client/renewal-approvals/{{invoiceId}}/change-plan", + "body": { + "mode": "raw", + "raw": "{\n \"newCustomerPlanId\": \"{{newCustomerPlanId}}\"\n}" + }, + "description": "Deactivates the old subscription, creates a new one on newCustomerPlanId, repoints the invoice to it and recalculates the new-round amount (any carried-forward 'Previous Balance' line is left untouched), sets IsActive=true." + } + }, + { + "name": "Reject", + "request": { + "method": "POST", + "url": "{{baseUrl}}/api/client/renewal-approvals/{{invoiceId}}/reject", + "description": "Sets invoice Status=Cancelled and deactivates the subscription entirely. Use a different {{invoiceId}} than the one you Approved/Change-Planned above, since each invoice can only be actioned once." + } + } + ] + }, + { + "name": "4. Verify After", + "item": [ + { + "name": "Get Member Invoices (scope=latest)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/client/member-invoices?scope=latest", + "description": "Approved+Rejected customers show their new state; a still-pending-approval customer is temporarily absent from this list until actioned (its invoice is IsActive=false)." + } + }, + { + "name": "List Renewal Approvals (remaining)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/client/renewal-approvals", + "description": "Shows whatever you haven't actioned yet out of the 4 seeded invoices." + } + } + ] + } + ] +} diff --git a/scripts/add-face-photo-columns.sql b/scripts/add-face-photo-columns.sql new file mode 100644 index 0000000..25efb61 --- /dev/null +++ b/scripts/add-face-photo-columns.sql @@ -0,0 +1,93 @@ +-- Face photo enrolment columns on AccessProfile (tenant DBs). +-- +-- TenantDbContext has no EF migration history -- its schema is created by EnsureCreated -- +-- so schema changes are applied with an idempotent script, matching how this project +-- already works. Safe to re-run: each ADD COLUMN is skipped when the column exists. +-- +-- The face image itself is NEVER stored here. It travels in DeviceCommand.PayloadJson only +-- until a Hikvision terminal confirms enrolment, at which point AgentService purges the +-- payload. What these columns keep is the enrolment state plus the device-side record id, +-- which is what a later replace/delete needs. +-- +-- Run once per tenant database. Replace the USE line, or run the whole file per schema: +-- +-- mysql -u root hexa_tenant_ironcore < add-face-photo-columns.sql +-- mysql -u root hexa_tenant_pulsefit < add-face-photo-columns.sql +-- mysql -u root hexa_tenant_flexzone < add-face-photo-columns.sql +-- mysql -u root hexa_tenant_titanfit < add-face-photo-columns.sql + +DROP PROCEDURE IF EXISTS add_face_photo_columns; + +DELIMITER // + +CREATE PROCEDURE add_face_photo_columns() +BEGIN + -- True once a terminal has accepted a face for this profile. + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'accessprofiles' + AND COLUMN_NAME = 'HasFacePhoto' + ) THEN + ALTER TABLE accessprofiles + ADD COLUMN HasFacePhoto tinyint(1) NOT NULL DEFAULT 0; + END IF; + + -- None | Pending | Synced | Failed. + -- Distinguishes "still uploading" from "the terminal rejected the face", which + -- HasFacePhoto alone cannot express. + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'accessprofiles' + AND COLUMN_NAME = 'FacePhotoStatus' + ) THEN + ALTER TABLE accessprofiles + ADD COLUMN FacePhotoStatus varchar(20) NOT NULL DEFAULT 'None'; + END IF; + + -- Hikvision face-record id (FPID) returned on enrolment; the handle for replace/delete. + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'accessprofiles' + AND COLUMN_NAME = 'FacePhotoFPID' + ) THEN + ALTER TABLE accessprofiles + ADD COLUMN FacePhotoFPID varchar(64) NULL; + END IF; + + -- Why the terminal rejected the face (not frontal, eyes closed, poor lighting). + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'accessprofiles' + AND COLUMN_NAME = 'FacePhotoError' + ) THEN + ALTER TABLE accessprofiles + ADD COLUMN FacePhotoError varchar(500) NULL; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'accessprofiles' + AND COLUMN_NAME = 'FacePhotoUpdatedAt' + ) THEN + ALTER TABLE accessprofiles + ADD COLUMN FacePhotoUpdatedAt datetime(6) NULL; + END IF; +END // + +DELIMITER ; + +CALL add_face_photo_columns(); +DROP PROCEDURE add_face_photo_columns; + +-- Verification: should list all five columns. +SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'accessprofiles' + AND (COLUMN_NAME LIKE 'FacePhoto%' OR COLUMN_NAME = 'HasFacePhoto') +ORDER BY COLUMN_NAME; diff --git a/scripts/agent-face-photo-contract.md b/scripts/agent-face-photo-contract.md new file mode 100644 index 0000000..5f848ae --- /dev/null +++ b/scripts/agent-face-photo-contract.md @@ -0,0 +1,150 @@ +# Agent contract — `UpsertFacePhoto` + +**Status: implemented on both sides.** The agent side lives in `Access-device-agent` +(`Devices/HikvisionIsapiClient.SetUserFaceAsync` and the `UpsertFacePhoto` case in +`Services/CommandWorker.DispatchAsync`). This document remains the reference for the wire +format and the device call. + +Not yet verified against a physical terminal — see "Untested" at the end. + +## Flow + +``` +Web ──POST client/customers/{id}/face-photo──▶ Server + │ validates JPEG, queues DeviceCommand + ▼ +Agent ──GET api/agent/commands ─────────────────▶ receives UpsertFacePhoto + ──ISAPI POST────────────────────▶ Hikvision terminal (gym LAN) + ──POST api/agent/commands/{id}/result ────▶ Server + │ purges payload, updates profile + ▼ + SignalR "FacePhotoUpdated" ──▶ browser +``` + +## Command the agent receives + +`GET api/agent/commands` returns the usual envelope; `CommandType` is `UpsertFacePhoto`. + +`PayloadJson`: + +```json +{ + "bindingId": "8f1c...", // guid, AccessDeviceBinding + "accessProfileId": 6, // long + "employeeNo": "HF6", // the person id already used by UpsertPerson + "name": "Prabodha", + "faceJpegBase64": "/9j/4AAQ...", // bare base64, no data: prefix + "desiredVersion": 4 +} +``` + +The image is already normalised by the browser and re-checked server-side: + +| Property | Guaranteed | +|---|---| +| Format | JPEG (magic bytes verified) | +| Dimensions | 480×640 from the web client; ≤ 1920×1920 enforced | +| Size | ≤ 200 KB enforced, typically 60–150 KB | + +So the agent does **not** need to resize or convert. Decode the base64 and POST it. + +**Ordering:** the person record must exist on the terminal before a face can attach to it. +`employeeNo` is the same value `UpsertPerson` uses. If a face command arrives for a person +the terminal doesn't have, run the person upsert first rather than failing. + +## ISAPI call + +Face data is attached to an existing person in a face library. Typical shape for the +DS-K1T series (confirm against your firmware — this varies more than Hikvision's docs +suggest): + +``` +POST /ISAPI/Intelligent/FDLib/FDSetUp?format=json +Content-Type: multipart/form-data; boundary=---boundary +Authorization: Digest ... + +---boundary +Content-Disposition: form-data; name="FaceDataRecord" +Content-Type: application/json + +{ "faceLibType": "blackFD", "FDID": "1", "FPID": "HF6", "name": "Prabodha" } +---boundary +Content-Disposition: form-data; name="FaceImage"; filename="face.jpg" +Content-Type: image/jpeg + + +---boundary-- +``` + +- `FPID` should be the `employeeNo`, so the face ties to the same person record. +- Use **Digest** auth, as with the other endpoints. +- Replacing a photo is the same call — the device overwrites the FPID's face. + +## Result the agent posts back + +`POST api/agent/commands/{id}/result` + +**Success:** +```json +{ "success": true, "resultJson": "{\"fpid\":\"HF6\"}" } +``` + +The server reads `fpid` (also accepts `FPID`, `faceId`, `faceURL`) into +`AccessProfile.FacePhotoFPID` — the handle needed to replace or delete the face later. +Anything longer than 64 chars is truncated. + +**Failure:** +```json +{ "success": false, "resultJson": "{\"detail\":\"No frontal face detected in the image\"}" } +``` + +Put a human-readable reason in `detail` — it is surfaced to gym staff verbatim, so it +should say what to do about it. Real Hikvision rejections worth mapping: + +| Device response | Suggested `detail` | +|---|---| +| `lowFaceQuality` / no face found | `No clear frontal face was detected. Retake with the face centred and well lit.` | +| face already bound to another FPID | `This face is already enrolled for another member.` | +| library full | `The terminal's face library is full.` | +| image format rejected | `The terminal rejected the image format.` | + +## Retry behaviour + +`MaxAttempts` is **3** for face commands (lower than the 5 used for person/state commands): +a face rejected for quality will be rejected identically on every retry, so failing fast +gets the real message in front of staff sooner. + +- Retries use the existing exponential backoff. +- On success **or** final failure the server clears `PayloadJson`, so the image is not + retained. A retry between those points still has the payload available. +- A newer photo supersedes an older queued one — the stale command is marked `Superseded` + and its payload cleared. The agent should treat `Superseded` as "stop, discard". + +## Deleting a face + +The **agent** side is implemented (`DeleteUserFaceAsync`, plus a `DeleteFacePhoto` dispatch +case), calling: + +``` +PUT /ISAPI/Intelligent/FDLib/FDSearch/Delete?format=json&FDID=1&faceLibType=blackFD +{ "FPID": [ { "value": "HF6" } ] } +``` + +The **server** does not queue this command yet — nothing calls it when a member is removed. +Adding that is a small change: queue `DeleteFacePhoto` with the stored `FacePhotoFPID` +wherever a member is deactivated, then clear the face columns on success. + +## Untested + +Everything here compiles and the wire format matches on both sides, but **no part of this +has run against a physical Hikvision terminal.** Expect to verify: + +- `FDID = "1"` and `faceLibType = "blackFD"` are the right library on your model. Terminals + with multiple face libraries may need a different FDID. +- The multipart part names (`FaceDataRecord`, `FaceImage`) — these vary between firmware + generations more than Hikvision's documentation suggests. +- Whether your model accepts 480×640 at ~150 KB, or wants smaller. + +The agent logs the full device response for both success and failure +(`SetUserFace {EmployeeNo} ... : {Response}`), which is the fastest way to see what the +terminal actually objects to on the first real run. diff --git a/scripts/check-layering.ps1 b/scripts/check-layering.ps1 new file mode 100644 index 0000000..e8aec9b --- /dev/null +++ b/scripts/check-layering.ps1 @@ -0,0 +1,46 @@ +param( + [string]$ProjectRoot = (Split-Path -Parent $PSScriptRoot) +) + +$ErrorActionPreference = 'Stop' +$violations = [System.Collections.Generic.List[string]]::new() + +function Add-Matches { + param( + [string]$Label, + [string]$Pattern, + [string]$Path + ) + + $matches = & rg --line-number --glob '*.cs' $Pattern $Path 2>$null + if ($LASTEXITCODE -eq 0) { + foreach ($match in $matches) { + $violations.Add("${Label}: ${match}") + } + } + elseif ($LASTEXITCODE -ne 1) { + throw "Architecture scan failed while checking ${Label}." + } +} + +Add-Matches ` + -Label 'Service database dependency' ` + -Pattern 'AppDBContext|Microsoft\.EntityFrameworkCore|GetRequiredService' ` + -Path (Join-Path $ProjectRoot 'Services') + +Add-Matches ` + -Label 'Controller repository dependency' ` + -Pattern 'I[A-Za-z0-9]+Repo(sitory)?\b' ` + -Path (Join-Path $ProjectRoot 'Controllers') + +Add-Matches ` + -Label 'Repository upward dependency or commit' ` + -Pattern 'Interfaces\.Services|I[A-Za-z0-9]+Service\b|IUnitOfWork|HttpContext|SaveChangesAsync\(' ` + -Path (Join-Path $ProjectRoot 'Repos') + +if ($violations.Count -gt 0) { + Write-Error ("Layering violations found:`n" + ($violations -join "`n")) + exit 1 +} + +Write-Output 'Layering check passed: Controller -> Service -> Repository -> AppDBContext.' diff --git a/scripts/drop-phantom-category-tables.sql b/scripts/drop-phantom-category-tables.sql new file mode 100644 index 0000000..fd1d095 --- /dev/null +++ b/scripts/drop-phantom-category-tables.sql @@ -0,0 +1,27 @@ +-- Cleanup for the phantom tenant-side plan-category tables. +-- +-- WorkoutCategory / MealCategory are central-only entities (CentralDbContext owns and +-- seeds them). Before TenantDbContext ignored them, EF's reachability discovery pulled +-- them into the tenant model via WorkoutPlan.WorkoutCategory / MealPlan.MealCategory, +-- which created these empty tenant tables plus a real FK from the plan tables to them. +-- Creating a gym's own plan then failed the constraint against a never-populated table. +-- +-- The FK constraints have already been dropped. These tables are the leftover husks: +-- they are empty, unreferenced, and no longer part of the EF model. Dropping them is +-- optional housekeeping -- nothing reads or writes them. +-- +-- Verify they are still empty before running (each should return 0): +-- SELECT COUNT(*) FROM hexa_tenant_ironcore.workoutcategory; +-- SELECT COUNT(*) FROM hexa_tenant_ironcore.mealcategory; + +DROP TABLE IF EXISTS hexa_tenant_flexzone.workoutcategory; +DROP TABLE IF EXISTS hexa_tenant_flexzone.mealcategory; + +DROP TABLE IF EXISTS hexa_tenant_ironcore.workoutcategory; +DROP TABLE IF EXISTS hexa_tenant_ironcore.mealcategory; + +DROP TABLE IF EXISTS hexa_tenant_pulsefit.workoutcategory; +DROP TABLE IF EXISTS hexa_tenant_pulsefit.mealcategory; + +DROP TABLE IF EXISTS hexa_tenant_titanfit.workoutcategory; +DROP TABLE IF EXISTS hexa_tenant_titanfit.mealcategory; diff --git a/tools/Update-Md5Reference.ps1 b/tools/Update-Md5Reference.ps1 new file mode 100644 index 0000000..eb8bbc8 --- /dev/null +++ b/tools/Update-Md5Reference.ps1 @@ -0,0 +1,221 @@ +param( + [string]$OutputPath = "PROJECT_MD5_REFERENCE.md", + [switch]$Verify +) + +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$fallbackRoot = Resolve-Path -LiteralPath (Join-Path $scriptDir "..") + +try { + $gitRoot = git -C $fallbackRoot rev-parse --show-toplevel 2>$null + if ([string]::IsNullOrWhiteSpace($gitRoot)) { + $repoRoot = $fallbackRoot.Path + } + else { + $repoRoot = (Resolve-Path -LiteralPath $gitRoot.Trim()).Path + } +} +catch { + $repoRoot = $fallbackRoot.Path +} + +Push-Location $repoRoot +try { + $normalizedOutputPath = $OutputPath.Replace("\", "/") + + $files = @() + try { + $files = git ls-files --cached --modified --others --exclude-standard | + ForEach-Object { $_.Replace("\", "/") } | + Where-Object { $_ -and $_ -ne $normalizedOutputPath } + } + catch { + $ignoredDirectories = @(".git", ".vs", "bin", "obj", "publish", "Migrations") + $ignoredExtensions = @(".user", ".suo", ".userosscache", ".sln.docstates", ".log", ".zip") + + $files = Get-ChildItem -LiteralPath $repoRoot -Recurse -File | + Where-Object { + $relative = [System.IO.Path]::GetRelativePath($repoRoot, $_.FullName).Replace("\", "/") + $parts = $relative -split "/" + $extension = [System.IO.Path]::GetExtension($_.Name) + + ($ignoredDirectories | Where-Object { $parts -contains $_ }).Count -eq 0 -and + ($ignoredExtensions -notcontains $extension) -and + $relative -ne $normalizedOutputPath + } | + ForEach-Object { [System.IO.Path]::GetRelativePath($repoRoot, $_.FullName).Replace("\", "/") } + } + + $files = $files | Sort-Object -Unique + + $hashRows = foreach ($file in $files) { + $fullPath = Join-Path $repoRoot $file + if (Test-Path -LiteralPath $fullPath -PathType Leaf) { + $item = Get-Item -LiteralPath $fullPath + $hash = Get-FileHash -LiteralPath $fullPath -Algorithm MD5 + + [PSCustomObject]@{ + Path = $file + Size = $item.Length + MD5 = $hash.Hash.ToLowerInvariant() + } + } + } + + $statusLines = @() + try { + $statusLines = git status --short | + Where-Object { + $statusPath = ($_ -replace "^[ MADRCU?!]{2}\s+", "").Replace("\", "/") + $statusPath -ne $normalizedOutputPath + } + } + catch { + $statusLines = @() + } + + if ($Verify) { + if (-not (Test-Path -LiteralPath $OutputPath -PathType Leaf)) { + throw "Cannot verify because $OutputPath does not exist." + } + + $expectedRows = Get-Content -LiteralPath $OutputPath | + ForEach-Object { + if ($_ -match "^\| (?.+?) \| (?\d+) \| (?[a-f0-9]{32}) \|$") { + [PSCustomObject]@{ + Path = $Matches.Path + Size = [int64]$Matches.Size + MD5 = $Matches.MD5 + } + } + } + + $expectedByPath = @{} + foreach ($row in $expectedRows) { + $expectedByPath[$row.Path] = $row + } + + $currentByPath = @{} + foreach ($row in $hashRows) { + $currentByPath[$row.Path] = $row + } + + $added = @($currentByPath.Keys | Where-Object { -not $expectedByPath.ContainsKey($_) } | Sort-Object) + $removed = @($expectedByPath.Keys | Where-Object { -not $currentByPath.ContainsKey($_) } | Sort-Object) + $changed = @( + foreach ($path in $expectedByPath.Keys) { + if ($currentByPath.ContainsKey($path) -and $currentByPath[$path].MD5 -ne $expectedByPath[$path].MD5) { + "$path ($($expectedByPath[$path].MD5) -> $($currentByPath[$path].MD5))" + } + } + ) | Sort-Object + + if ($added.Count -eq 0 -and $removed.Count -eq 0 -and $changed.Count -eq 0) { + Write-Host "MD5 reference verified. No changes detected across $($hashRows.Count) files." + return + } + + Write-Host "MD5 reference verification found differences." + if ($added.Count -gt 0) { + Write-Host "" + Write-Host "Added files:" + $added | ForEach-Object { Write-Host " $_" } + } + if ($removed.Count -gt 0) { + Write-Host "" + Write-Host "Removed files:" + $removed | ForEach-Object { Write-Host " $_" } + } + if ($changed.Count -gt 0) { + Write-Host "" + Write-Host "Changed files:" + $changed | ForEach-Object { Write-Host " $_" } + } + + exit 1 + } + + $projectName = Split-Path -Leaf $repoRoot + $generatedAt = Get-Date -Format "yyyy-MM-dd HH:mm:ss zzz" + $targetFramework = "" + if (Test-Path -LiteralPath "Fitness_core_BE.csproj") { + $projectXml = [xml](Get-Content -Raw -LiteralPath "Fitness_core_BE.csproj") + $targetFramework = $projectXml.Project.PropertyGroup.TargetFramework + } + + $topLevelCounts = $hashRows | + Group-Object { + if ($_.Path.Contains("/")) { + ($_.Path -split "/")[0] + } + else { + "." + } + } | + Sort-Object Name + + $lines = [System.Collections.Generic.List[string]]::new() + $lines.Add("# Project MD5 Reference") + $lines.Add("") + $lines.Add("Generated: $generatedAt") + $lines.Add("") + $lines.Add("Project: $projectName") + if (-not [string]::IsNullOrWhiteSpace($targetFramework)) { + $lines.Add("") + $lines.Add("Target framework: $targetFramework") + } + $lines.Add("") + $lines.Add("Purpose: baseline MD5 hashes for future implementation checks. MD5 is used here for change detection, not for security-sensitive integrity verification.") + $lines.Add("") + $lines.Add('Manifest scope: Git tracked files plus untracked files not ignored by `.gitignore`; build outputs, IDE files, ignored migrations, and this manifest are excluded.') + $lines.Add("") + $lines.Add("Regenerate command:") + $lines.Add("") + $lines.Add('```powershell') + $lines.Add('.\tools\Update-Md5Reference.ps1') + $lines.Add('```') + $lines.Add("") + $lines.Add("Verify command:") + $lines.Add("") + $lines.Add('```powershell') + $lines.Add('.\tools\Update-Md5Reference.ps1 -Verify') + $lines.Add('```') + $lines.Add("") + $lines.Add("## Project Shape") + $lines.Add("") + $lines.Add("| Area | Files |") + $lines.Add("| --- | ---: |") + foreach ($group in $topLevelCounts) { + $lines.Add("| $($group.Name) | $($group.Count) |") + } + $lines.Add("") + $lines.Add("## Git Status At Baseline") + $lines.Add("") + if ($statusLines.Count -gt 0) { + $lines.Add('```text') + foreach ($status in $statusLines) { + $lines.Add($status) + } + $lines.Add('```') + } + else { + $lines.Add("Clean working tree or Git status unavailable.") + } + $lines.Add("") + $lines.Add("## MD5 Hashes") + $lines.Add("") + $lines.Add("| Path | Size Bytes | MD5 |") + $lines.Add("| --- | ---: | --- |") + foreach ($row in $hashRows) { + $lines.Add("| $($row.Path) | $($row.Size) | $($row.MD5) |") + } + $lines.Add("") + + Set-Content -LiteralPath $OutputPath -Value $lines -Encoding UTF8 + Write-Host "Wrote $OutputPath with $($hashRows.Count) MD5 hashes." +} +finally { + Pop-Location +}