first commit

This commit is contained in:
Dhananjaya99
2026-09-28 11:44:26 +05:30
parent 721743ae6b
commit bffc9b1686
424 changed files with 46474 additions and 0 deletions
@@ -0,0 +1,17 @@
// <auto-generated/>
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;
+36
View File
@@ -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
+17
View File
@@ -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"
]
}
}
+68
View File
@@ -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<string> 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.");
}
}
}
+7
View File
@@ -0,0 +1,7 @@
namespace Fitness_core_BE.AI
{
public interface IAiClient
{
Task<string> GenerateJsonAsync(string systemPrompt, object payload);
}
}
+213
View File
@@ -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<object> 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<object>(jsonResponse);
if (parsed == null)
throw new Exception("Invalid AI JSON");
}
catch
{
throw new Exception("AI returned invalid JSON structure");
}
return JsonSerializer.Deserialize<object>(jsonResponse)!;
}
public async Task<object> 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<object>(jsonResponse);
if (parsed == null)
throw new Exception("Invalid JSON");
}
catch
{
throw new Exception("AI returned invalid JSON");
}
return JsonSerializer.Deserialize<object>(jsonResponse)!;
}
}
}
+47
View File
@@ -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.");
}
}
}
+229
View File
@@ -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<object> 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<object>(jsonResponse);
if (parsed == null)
throw new Exception("Invalid AI JSON");
}
catch
{
throw new Exception("AI returned invalid JSON structure");
}
return JsonSerializer.Deserialize<object>(jsonResponse)!;
}
public async Task<object> 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<object>(jsonResponse);
if (parsed == null)
throw new Exception("Invalid JSON");
}
catch
{
throw new Exception("AI returned invalid JSON");
}
return JsonSerializer.Deserialize<object>(jsonResponse)!;
}
}
}
+79
View File
@@ -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
{
/// <summary>
/// Constants for the agent API-key authentication scheme / authorization policy.
/// </summary>
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 { }
/// <summary>
/// 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.
/// </summary>
public class AgentApiKeyAuthenticationHandler : AuthenticationHandler<AgentApiKeyAuthenticationOptions>
{
private readonly IAgentRepo _agentRepo;
public AgentApiKeyAuthenticationHandler(
IOptionsMonitor<AgentApiKeyAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IAgentRepo agentRepo)
: base(options, logger, encoder)
{
_agentRepo = agentRepo;
}
protected override async Task<AuthenticateResult> 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);
}
}
}
+97
View File
@@ -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 { }
/// <summary>
/// Authenticates browser SignalR connections from the existing httpOnly
/// access_token cookie. MVC controllers retain their existing action filter.
/// </summary>
public sealed class UserJwtAuthenticationHandler
: AuthenticationHandler<UserJwtAuthenticationOptions>
{
private readonly IConfiguration _configuration;
public UserJwtAuthenticationHandler(
IOptionsMonitor<UserJwtAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IConfiguration configuration)
: base(options, logger, encoder)
{
_configuration = configuration;
}
protected override Task<AuthenticateResult> 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<byte>();
var exponent = parameters.Exponent ?? Array.Empty<byte>();
var material =
$"{Convert.ToBase64String(modulus)}.{Convert.ToBase64String(exponent)}";
return Base64UrlEncoder.Encode(
SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(material)));
}
}
}
+223
View File
@@ -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<CentralDbContext> options) : base(options) { }
// Identity & tenant registry
public DbSet<User> Users => Set<User>();
public DbSet<Admin> Admins => Set<Admin>();
public DbSet<Customer> Customer => Set<Customer>();
public DbSet<Client> Client => Set<Client>();
public DbSet<ClientBusiness> ClientBusiness => Set<ClientBusiness>();
// 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<Agent> Agents => Set<Agent>();
public DbSet<PairingCode> PairingCodes => Set<PairingCode>();
// 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<JobRunLog> JobRunLogs => Set<JobRunLog>();
// Customer-follows-you-everywhere data
public DbSet<CustomerHealth> CustomerHealth => Set<CustomerHealth>();
public DbSet<CustomerTarget> CustomerTargets => Set<CustomerTarget>();
public DbSet<CusWeightLossProgress> CusWeightLossProgress => Set<CusWeightLossProgress>();
public DbSet<CustomerNotification> CustomerNotifications => Set<CustomerNotification>();
// Admin-authored plan templates (IsTemplate=true / FKClientId=null rows only)
public DbSet<MealCategory> MealCategories => Set<MealCategory>();
public DbSet<MealPlan> MealPlans => Set<MealPlan>();
public DbSet<Meal> Meal => Set<Meal>();
public DbSet<WorkoutCategory> WorkoutCategories => Set<WorkoutCategory>();
public DbSet<WorkoutPlan> WorkoutPlans => Set<WorkoutPlan>();
public DbSet<WorkoutItem> WorkoutItems => Set<WorkoutItem>();
public DbSet<WorkoutExercise> WorkoutExercises => Set<WorkoutExercise>();
// Support / global catalog
public DbSet<Ticket> Tickets => Set<Ticket>();
public DbSet<TicketCategory> TicketCategories => Set<TicketCategory>();
public DbSet<TicketMessage> TicketMessages => Set<TicketMessage>();
public DbSet<NavItem> NavItems => Set<NavItem>();
public DbSet<UserDocument> UserDocuments => Set<UserDocument>();
public DbSet<DocumentTypes> DocumentTypes => Set<DocumentTypes>();
// Platform billing (HexaFitness -> gym)
public DbSet<ClientInvoice> ClientInvoices => Set<ClientInvoice>();
public DbSet<ClientCard> ClientCard => Set<ClientCard>();
public DbSet<ClientInvoiceItem> ClientInvoiceItems => Set<ClientInvoiceItem>();
public DbSet<ClientSubscription> ClientSubscriptions => Set<ClientSubscription>();
public DbSet<ClientSubItems> ClientSubItems => Set<ClientSubItems>();
public DbSet<ClientPackage> ClientPackages => Set<ClientPackage>();
public DbSet<ClientPayoutKyc> ClientPayoutKycs => Set<ClientPayoutKyc>();
public DbSet<ClientWallet> ClientWallets => Set<ClientWallet>();
public DbSet<WalletLedgerEntry> WalletLedgerEntries => Set<WalletLedgerEntry>();
public DbSet<PayoutBatch> PayoutBatches => Set<PayoutBatch>();
public DbSet<DevicePricingConfig> DevicePricingConfigs => Set<DevicePricingConfig>();
public DbSet<ClientDeviceBilling> ClientDeviceBillings => Set<ClientDeviceBilling>();
// Row-split: only rows with FK_ClientInvoiceId set (platform billing payments) live
// here -- member payments (FK_CustomerInvoiceId set) live in TenantDbContext instead.
public DbSet<PaymentTransaction> PaymentTransactions => Set<PaymentTransaction>();
// Ambiguous scope today (no FKClientId on the model) -- kept central pending the
// Phase-2 confirmation spike called out in the migration plan.
public DbSet<FeedPost> FeedPosts => Set<FeedPost>();
public DbSet<FeedLike> FeedLikes => Set<FeedLike>();
public DbSet<ActivityLog> ActivityLogs => Set<ActivityLog>();
public DbSet<Chat> Chat => Set<Chat>();
public DbSet<ChatMessages> ChatMessages => Set<ChatMessages>();
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<TenantCustomerLink>();
modelBuilder.Ignore<Staff>();
modelBuilder.Ignore<StaffPayRate>();
modelBuilder.Ignore<StaffPayment>();
modelBuilder.Ignore<GymRole>();
modelBuilder.Ignore<GymRolePermission>();
modelBuilder.Ignore<CustomerPlan>();
modelBuilder.Ignore<CustomerSubscription>();
modelBuilder.Ignore<SubscriptionMember>();
modelBuilder.Ignore<SubscriptionInstallment>();
modelBuilder.Ignore<CustomerSubItem>();
modelBuilder.Ignore<CustomerInvoice>();
modelBuilder.Ignore<CustomerInvoiceItem>();
modelBuilder.Ignore<CustomerCards>();
modelBuilder.Ignore<LedgerEntry>();
modelBuilder.Ignore<ClientTerms>();
modelBuilder.Ignore<CustomerTermsAcceptance>();
modelBuilder.Ignore<AccessDevice>();
modelBuilder.Ignore<AccessDeviceBinding>();
modelBuilder.Ignore<AccessProfile>();
modelBuilder.Ignore<UnlinkedDeviceEnrollment>();
modelBuilder.Ignore<CustomerAccess>();
modelBuilder.Ignore<DeviceCommand>();
modelBuilder.Ignore<Attendance>();
modelBuilder.Ignore<CustomerMeal>();
modelBuilder.Ignore<CustomerWorkout>();
modelBuilder.Ignore<CustomerWorkoutExerciseCompletion>();
modelBuilder.Entity<Agent>()
.HasIndex(a => a.ApiKeyHash)
.IsUnique();
modelBuilder.Entity<JobRunLog>()
.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<MealPlan>().Ignore(p => p.Client);
modelBuilder.Entity<WorkoutPlan>().Ignore(p => p.Client);
modelBuilder.Entity<PaymentTransaction>().Ignore(p => p.Client);
modelBuilder.Entity<PaymentTransaction>().Ignore(p => p.Customer);
modelBuilder.Entity<PaymentTransaction>().Ignore(p => p.CustomerCard);
modelBuilder.Entity<PaymentTransaction>().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<Customer>().Ignore(c => c.CustomerWorkouts);
modelBuilder.Entity<Customer>().Ignore(c => c.CustomerMeals);
modelBuilder.Entity<Customer>().Ignore(c => c.CustomerSubscriptions);
modelBuilder.Entity<NavItem>().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<DocumentTypes>().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<User>().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<WorkoutCategory>().HasData(Data.WorkoutPlanSeedData.Categories());
modelBuilder.Entity<WorkoutPlan>().HasData(Data.WorkoutPlanSeedData.Plans());
modelBuilder.Entity<WorkoutItem>().HasData(Data.WorkoutPlanSeedData.Items());
modelBuilder.Entity<WorkoutExercise>().HasData(Data.WorkoutPlanSeedData.Exercises());
}
}
public class CentralDbContextFactory : IDesignTimeDbContextFactory<CentralDbContext>
{
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<CentralDbContext>();
var connectionString = config.GetConnectionString("DefaultConnection");
optionsBuilder.UseMySql(connectionString, new MySqlServerVersion(new Version(8, 0, 0)));
return new CentralDbContext(optionsBuilder.Options);
}
}
}
+365
View File
@@ -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
{
/// <summary>
/// Main API Controller for handling various operations
/// </summary>
[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;
}
/// <summary>
/// Check API health status
/// </summary>
/// <returns>API status and current timestamp</returns>
/// <response code="200">Returns API status</response>
[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<IActionResult> ExecuteTicket([FromBody] ApiRequest request)
{
var response = await _ticketFuncHandler.Execute(request, HttpContext);
return StatusCode(response.StatusCode, response);
}
[HttpPost("activitylog")]
public async Task<IActionResult> ExecuteActivityLog([FromBody] ApiRequest request)
{
var response = await _activityLogFuncHandler.ExecuteAsync(request.FunctionName, request.Payload, HttpContext);
return Ok(new ApiResponse<object>
{
StatusCode = 200,
Message = "Success",
Data = response
});
}
[HttpPost("client")]
[CustomMiddleware]
[TenantResolution]
public async Task<IActionResult> ExecuteClient([FromBody] ApiRequest request)
{
var response = await _clientFuncHandler.Execute(request, HttpContext);
return StatusCode(response.StatusCode, response);
}
[HttpPost("auth")]
public async Task<IActionResult> ExecuteAuth([FromBody] ApiRequest request)
{
var response = await _authFuncHandler.Execute(request, HttpContext);
return StatusCode(response.StatusCode, response);
}
[HttpPost("VerifyOTP")]
[CustomMiddleware]
[TenantResolution]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> ExecuteStaff([FromBody] ApiRequest request)
{
var response = await _staffFuncHandler.Execute(request, HttpContext);
return StatusCode(response.StatusCode, response);
}
[HttpPost("clientinvoice")]
[CustomMiddleware]
[TenantResolution]
public async Task<IActionResult> ExecuteClientInvoice([FromBody] ApiRequest request)
{
var response = await _clientInvoiceFuncHandler.Execute(request, HttpContext);
return StatusCode(response.StatusCode, response);
}
[HttpPost("customer")]
[CustomMiddleware]
[TenantResolution]
public async Task<IActionResult> ExecuteCustomer([FromBody] ApiRequest request)
{
var response = await _customerFuncHandler.Execute(request, HttpContext);
return StatusCode(response.StatusCode, response);
}
[HttpPost("customerinvoice")]
[CustomMiddleware]
[TenantResolution]
public async Task<IActionResult> ExecuteCustomerInvoice([FromBody] ApiRequest request)
{
var response = await _customerInvoiceFuncHandler.Execute(request, HttpContext);
return StatusCode(response.StatusCode, response);
}
[HttpPost("customerplan")]
[CustomMiddleware]
[TenantResolution]
public async Task<IActionResult> ExecuteCustomerPlan([FromBody] ApiRequest request)
{
var response = await _customerPlanFuncHandler.Execute(request, HttpContext);
return StatusCode(response.StatusCode, response);
}
[HttpPost("customersubscription")]
[CustomMiddleware]
[TenantResolution]
public async Task<IActionResult> ExecuteCustomerSubscription([FromBody] ApiRequest request)
{
var response = await _customerSubscriptionFuncHandler.Execute(request, HttpContext);
return StatusCode(response.StatusCode, response);
}
[HttpPost("meal")]
[CustomMiddleware]
[TenantResolution]
public async Task<IActionResult> ExecuteMeal([FromBody] ApiRequest request)
{
var response = await _mealFuncHandler.Execute(request, HttpContext);
return StatusCode(response.StatusCode, response);
}
[HttpPost("workout")]
[CustomMiddleware]
[TenantResolution]
public async Task<IActionResult> ExecuteWorkout([FromBody] ApiRequest request)
{
var response = await _workoutFuncHandler.Execute(request, HttpContext);
return StatusCode(response.StatusCode, response);
}
[HttpPost("document")]
[CustomMiddleware]
[TenantResolution]
public async Task<IActionResult> ExecuteDocument([FromBody] ApiRequest request)
{
var response = await _documentFuncHandler.Execute(request, HttpContext);
return StatusCode(response.StatusCode, response);
}
[HttpPost("feed")]
[CustomMiddleware]
[TenantResolution]
public async Task<IActionResult> ExecuteFeed([FromBody] ApiRequest request)
{
var response = await _feedFuncHandler.Execute(request, HttpContext);
return StatusCode(response.StatusCode, response);
}
[HttpPost("accessdevice")]
public async Task<IActionResult> ExecuteAccessDevice([FromBody] ApiRequest request)
{
var response = await _accessDeviceFuncHandler.Execute(request, HttpContext);
return StatusCode(response.StatusCode, response);
}
[HttpPost("attendance")]
[CustomMiddleware]
[TenantResolution]
public async Task<IActionResult> ExecuteAttendance([FromBody] ApiRequest request)
{
var response = await _attendanceFuncHandler.Execute(request, HttpContext);
return StatusCode(response.StatusCode, response);
}
[HttpPost("chat")]
[CustomMiddleware]
[TenantResolution]
public async Task<IActionResult> ExecuteChat([FromBody] ApiRequest request)
{
var response = await _chatFuncHandler.Execute(request);
return StatusCode(response.StatusCode, response);
}
[HttpPost("initChat")]
[CustomMiddleware]
[TenantResolution]
public async Task<IActionResult> InitChat([FromBody] ApiRequest request)
{
var response = await _chatFuncHandler.Execute(request);
return StatusCode(response.StatusCode, response);
}
[HttpPost("payment")]
[CustomMiddleware]
[TenantResolution]
public async Task<IActionResult> ExecutePayment([FromBody] ApiRequest request)
{
var response = await _paymentFuncHandler.Execute(request, HttpContext);
return StatusCode(response.StatusCode, response);
}
[HttpPost("clientstat")]
[CustomMiddleware]
[TenantResolution]
public async Task<IActionResult> ExecuteClientStat([FromBody] ApiRequest request)
{
var response = await _clientStatFuncHandler.Execute(request, HttpContext);
return StatusCode(response.StatusCode, response);
}
[HttpPost("register")]
public async Task<IActionResult> ExecuteRegsiter([FromBody] ApiRequest request)
{
var response = await _userRegisterHandler.Execute(request, HttpContext);
return StatusCode(response.StatusCode, response);
}
[HttpGet("docService/{docId}")]
[AllowAnonymous]
public async Task<IActionResult> 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}");
}
}
}
}
@@ -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<ActionResult<ApiResponse<IReadOnlyList<JobRunRecord>>>> 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<ActionResult<ApiResponse<List<SubscriptionRenewalRunResult>>>> TriggerRenewalNow(
[FromQuery] Guid? clientId, CancellationToken ct)
{
var now = DateTime.UtcNow;
var results = new List<SubscriptionRenewalRunResult>();
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<ActionResult<ApiResponse<object>>> TriggerOverdueLockNow(CancellationToken ct)
{
var now = DateTime.UtcNow;
var clients = await _overdueLockRepository.GetClientsWithOverdueLockPolicyAsync(ct);
var failures = new List<string>();
foreach (var client in clients)
{
try
{
await _overdueLockRunner.RunForClientAsync(client, now, _iterator, ct);
}
catch (Exception ex)
{
failures.Add($"{client.ClientId}: {ex.Message}");
}
}
return Response<object>(new
{
clientsChecked = clients.Count,
failed = failures.Count,
failures
});
}
}
}
+94
View File
@@ -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<ActionResult<ApiResponse<List<PricingPlanDto>>>> ListPricingPlans()
{
return Response(await _platformBilling.GetPricingPlansAsync(HttpContext.RequestAborted));
}
[HttpPut("pricing-plans")]
public async Task<ActionResult<ApiResponse<PricingPlanDto>>> UpsertPricingPlan([FromBody] UpsertPricingPlanRequest request)
{
return Response(await _platformBilling.UpsertPricingPlanAsync(request, HttpContext.RequestAborted));
}
[HttpGet("device-pricing")]
public async Task<ActionResult<ApiResponse<DevicePricingDto>>> GetDevicePricing()
{
return Response(await _platformBilling.GetDevicePricingAsync(HttpContext.RequestAborted));
}
[HttpPut("device-pricing")]
public async Task<ActionResult<ApiResponse<DevicePricingDto>>> UpdateDevicePricing([FromBody] DevicePricingDto request)
{
return Response(await _platformBilling.UpdateDevicePricingAsync(request, HttpContext.RequestAborted));
}
[HttpPost("clients/{id:guid}/pricing-plan")]
public async Task<ActionResult<ApiResponse<object>>> AssignPricingPlan(Guid id, [FromBody] AssignPricingPlanRequest request)
{
var invoice = await _platformBilling.AssignPricingPlanAsync(id, request, HttpContext.RequestAborted);
return Response<object>(new { assigned = true, clientInvoiceId = invoice.ClientInvoiceId, amount = invoice.Amount });
}
[HttpGet("clients/{id:guid}/platform-fee")]
public async Task<ActionResult<ApiResponse<PlatformFeeBreakdownDto>>> GetPlatformFee(Guid id)
{
return Response(await _platformBilling.ComputeMonthlyFeeAsync(id, HttpContext.RequestAborted));
}
[HttpGet("invoices/pending")]
public async Task<ActionResult<ApiResponse<List<PendingPlatformInvoiceDto>>>> ListPendingInvoices()
{
return Response(await _platformBilling.GetPendingInvoicesAsync(HttpContext.RequestAborted));
}
[HttpGet("invoices/completed")]
public async Task<ActionResult<ApiResponse<List<PendingPlatformInvoiceDto>>>> ListCompletedInvoices()
{
return Response(await _platformBilling.GetCompletedInvoicesAsync(HttpContext.RequestAborted));
}
[HttpPost("invoices/{id:guid}/payments")]
public async Task<ActionResult<ApiResponse<PendingPlatformInvoiceDto>>> 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<ActionResult<ApiResponse<object>>> SendInvoicePaymentLink(Guid id)
{
return Response(await _platformBilling.SendClientPaymentLinkAsync(id, HttpContext.RequestAborted));
}
[HttpPost("billing/run-monthly")]
public async Task<ActionResult<ApiResponse<object>>> RunMonthlyBilling()
{
var invoices = await _platformBilling.RunMonthlyBillingForAllClientsAsync(HttpContext.RequestAborted);
return Response<object>(new { generated = invoices.Count });
}
}
}
+99
View File
@@ -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<ActionResult<ApiResponse<List<ClientPayoutKycDto>>>> ListKyc([FromQuery] string? status)
{
return Response(await _kycReview.ListAsync(status));
}
[HttpGet("payout-kyc/{clientId:guid}")]
public async Task<ActionResult<ApiResponse<ClientPayoutKycDto>>> GetKyc(Guid clientId)
{
return Response(await _kycReview.GetByClientIdAsync(clientId));
}
[HttpPatch("payout-kyc/{clientId:guid}/status")]
public async Task<ActionResult<ApiResponse<ClientPayoutKycDto>>> ReviewKyc(Guid clientId, [FromBody] ReviewPayoutKycRequest request)
{
return Response(await _kycReview.ReviewAsync(clientId, request, HttpContext));
}
[HttpGet("clients")]
public async Task<ActionResult<ApiResponse<PagedResult<ClientSummary>>>> ListClients([FromQuery] ClientDirectoryQuery query)
{
return Response(await _clientDirectory.ListAsync(query));
}
[HttpGet("clients/{id:guid}")]
public async Task<ActionResult<ApiResponse<object>>> GetClient(Guid id)
{
return Response(await _clientDirectory.GetByIdAsync(id));
}
[HttpPost("clients/{id:guid}/pairing-code")]
public async Task<ActionResult<ApiResponse<PairingCodeResult>>> GeneratePairingCode(Guid id)
{
return Response(await _clientDirectory.GeneratePairingCodeAsync(id));
}
[HttpPost("clients/{id:guid}/resend-welcome-email")]
public async Task<ActionResult<ApiResponse<object>>> ResendWelcomeEmail(Guid id)
{
await _clientDirectory.ResendWelcomeEmailAsync(id, HttpContext);
return Response<object>(new { sent = true });
}
[HttpGet("clients/{id:guid}/access-devices")]
public async Task<ActionResult<ApiResponse<AdminClientDevicesResult>>> 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<ActionResult<ApiResponse<object>>> ListSuggestions()
=> Response(await _suggestions.ListAsync());
[HttpPost("suggestions/workouts")]
public async Task<ActionResult<ApiResponse<object>>> CreateWorkoutSuggestion([FromBody] AdminWorkoutSuggestionRequest request)
=> Response(await _suggestions.CreateWorkoutAsync(request, HttpContext));
[HttpPost("suggestions/meals")]
public async Task<ActionResult<ApiResponse<object>>> CreateMealSuggestion([FromBody] AdminMealSuggestionRequest request)
=> Response(await _suggestions.CreateMealAsync(request, HttpContext));
}
}
@@ -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<ActionResult<ApiResponse<List<GatewayDefinitionDto>>>> ListPaymentGateways()
{
return Response(await _paymentServiceClient.ListGatewaysAsync(HttpContext.RequestAborted));
}
[HttpPost("payment-gateways")]
public async Task<ActionResult<ApiResponse<GatewayDefinitionDto>>> UpsertPaymentGateway([FromBody] UpsertGatewayDefinitionRequest request)
{
return Response(await _paymentServiceClient.UpsertGatewayAsync(request, HttpContext.RequestAborted));
}
[HttpPut("payment-gateways/{id:guid}/enabled")]
public async Task<ActionResult<ApiResponse<GatewayDefinitionDto>>> 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<ActionResult<ApiResponse<object>>> UpsertPlatformGatewayCredential(
Guid platformId, string gatewayName, [FromBody] UpsertPlatformGatewayCredentialRequest request)
{
request.PlatformId = platformId;
request.GatewayName = gatewayName;
await _paymentServiceClient.UpsertPlatformCredentialAsync(request, HttpContext.RequestAborted);
return Response<object>(new { saved = true });
}
}
}
+153
View File
@@ -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
{
/// <summary>
/// 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.
/// </summary>
[Route("api/agent")]
[ApiController]
[Produces("application/json")]
[EnableCors("AllowAll")]
public class AgentController : ControllerBase
{
private readonly IAgentService _agentService;
private readonly ITimeBankCardService _timeBankCardService;
private readonly ILogger<AgentController> _logger;
public AgentController(IAgentService agentService, ITimeBankCardService timeBankCardService, ILogger<AgentController> 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<IActionResult> Enroll([FromBody] EnrollRequest request)
=> Execute(() => _agentService.EnrollAsync(request));
[HttpPost("devices")]
[Authorize(Policy = AgentApiKeyDefaults.PolicyName)]
public Task<IActionResult> RegisterDevice([FromBody] RegisterDeviceRequest request)
=> Execute(() => _agentService.RegisterDeviceAsync(GetClientId(), GetAgentId(), request));
[HttpPost("device-inventory")]
[Authorize(Policy = AgentApiKeyDefaults.PolicyName)]
public Task<IActionResult> ReconcileDeviceInventory([FromBody] DeviceInventoryRequest request)
=> Execute(() => _agentService.ReconcileDeviceInventoryAsync(
GetClientId(), GetAgentId(), request));
[HttpPost("attendance")]
[Authorize(Policy = AgentApiKeyDefaults.PolicyName)]
public Task<IActionResult> 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<IActionResult> GetCommands()
=> Execute(() => _agentService.GetPendingCommandsAsync(GetClientId(), GetAgentId()));
[HttpPost("commands/{id}/result")]
[Authorize(Policy = AgentApiKeyDefaults.PolicyName)]
public Task<IActionResult> 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<IActionResult> RedeemTimeBankCard([FromBody] AgentSwipeTimeBankCardRequest request)
=> Execute(() => _timeBankCardService.Swipe(new
{
rfidTagId = request.RfidTagId,
facilityId = request.FacilityId
}));
[HttpPost("heartbeat")]
[Authorize(Policy = AgentApiKeyDefaults.PolicyName)]
public async Task<IActionResult> Heartbeat()
{
// Accept an empty body from older agents during a rolling deployment.
var request = Request.HasJsonContentType()
? await Request.ReadFromJsonAsync<AgentHeartbeatRequest>()
: 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<IActionResult> Execute(Func<Task<object>> action)
{
try
{
var data = await action();
return StatusCode(200, new ApiResponse<object>
{
StatusCode = 200,
Message = "success",
Data = data
});
}
catch (UnauthorizedException ex)
{
return StatusCode(401, new ApiResponse<object> { StatusCode = 401, Message = ex.Message, Data = null! });
}
catch (NotFoundException ex)
{
return StatusCode(404, new ApiResponse<object> { StatusCode = 404, Message = ex.Message, Data = null! });
}
catch (BadRequestException ex)
{
return StatusCode(400, new ApiResponse<object> { StatusCode = 400, Message = ex.Message, Data = null! });
}
catch (ForbiddenException ex)
{
return StatusCode(403, new ApiResponse<object> { StatusCode = 403, Message = ex.Message, Data = null! });
}
catch (Exception ex)
{
return StatusCode(500, new ApiResponse<object> { StatusCode = 500, Message = $"An error occurred: {ex.Message}", Data = null! });
}
}
}
}
+103
View File
@@ -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<ActionResult<ApiResponse<object>>> CreateMemberBooking([FromBody] CreateMemberBookingRequest request)
=> Response(await _bookingService.CreateMemberBooking(new { facilitySessionId = request.FacilitySessionId }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpGet("api/app/bookings/mine")]
public async Task<ActionResult<ApiResponse<object>>> GetMyBookings()
=> Response(await _bookingService.GetMyBookings(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpPost("api/app/bookings/{id:guid}/cancel")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ContentResult> 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<ActionResult<ApiResponse<object>>> GetSessionBookings(Guid facilitySessionId)
=> Response(await _bookingService.GetSessionBookings(new { facilitySessionId }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("bookings")]
[HttpPost("api/client/bookings/check-in")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> CancelBooking(Guid id)
=> Response(await _bookingService.CancelBooking(new { bookingId = id }, HttpContext));
}
}
+43
View File
@@ -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<ActionResult<ApiResponse<object>>> BootstrapClub([FromBody] BootstrapClubRequest request)
=> Response(await _bootstrapService.BootstrapClub(new
{
userId = request.UserId,
clientName = request.ClientName,
clientPhone = request.ClientPhone,
clientEmail = request.ClientEmail
}));
}
}
File diff suppressed because it is too large Load Diff
+135
View File
@@ -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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<IActionResult> 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<ActionResult<ApiResponse<StartCardOnboardingResult>>> StartCardOnboarding(
[FromBody] StartCardOnboardingRequest request)
=> Response(await _clientPaymentMethodService.StartCardOnboardingAsync(HttpContext, request));
[CustomMiddleware]
[RequiresPermission("billing")]
[HttpGet("customer-invoices/cards/onboard/complete")]
public async Task<ActionResult<ApiResponse<CardOnboardingStatusResult>>> CompleteCardOnboarding([FromQuery] string sessionId)
=> Response(await _clientPaymentMethodService.CompleteCardOnboardingAsync(HttpContext, sessionId));
[CustomMiddleware]
[RequiresPermission("billing")]
[HttpGet("customer-invoices/cards")]
public async Task<ActionResult<ApiResponse<List<ClientCardDto>>>> ListCards()
=> Response(await _clientPaymentMethodService.ListCardsAsync(HttpContext));
[CustomMiddleware]
[RequiresPermission("billing")]
[HttpDelete("customer-invoices/cards/{cardToken}")]
public async Task<ActionResult<ApiResponse<object>>> RemoveCard(string cardToken)
{
await _clientPaymentMethodService.RemoveCardAsync(HttpContext, cardToken);
return Response<object>(new { removed = true });
}
[CustomMiddleware]
[RequiresPermission("billing")]
[HttpPatch("customer-invoices/cards/{cardToken}/default")]
public async Task<ActionResult<ApiResponse<ClientCardDto>>> SetDefaultCard(string cardToken)
=> Response(await _clientPaymentMethodService.SetDefaultCardAsync(HttpContext, cardToken));
[CustomMiddleware]
[RequiresPermission("billing")]
[HttpGet("customer-invoices/transactions")]
public async Task<ActionResult<ApiResponse<TransactionHistoryResult>>> 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; }
}
}
}
+715
View File
@@ -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<ApiResponse<object>> 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<ActionResult<ApiResponse<object>>?> RequireVerifiedAsync()
{
if (await _customerService.IsCustomerVerified(HttpContext))
return null;
return Response<object>(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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> RefreshToken([FromBody] RefreshTokenRequest request)
=> FromAuth(await _authService.RefreshToken(new { refreshToken = request.RefreshToken }, HttpContext));
[AllowAnonymous]
[HttpGet("auth/availability")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> Logout()
=> FromAuth(await _authService.LogOut(new { }, HttpContext));
[AllowAnonymous]
[HttpPost("account/register")]
public async Task<ActionResult<ApiResponse<object>>> RegisterCustomer([FromBody] JsonElement request)
=> Response(await _customerService.CreateCustomer(request, HttpContext));
[AllowAnonymous]
[HttpPost("account/password/forgot")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<IActionResult> ExecuteCustomer([FromBody] ApiRequest request)
{
var response = await _customerFuncHandler.Execute(request, HttpContext);
return StatusCode(response.StatusCode, response);
}
[AllowAnonymous]
[HttpGet("customer/exists")]
public async Task<ActionResult<ApiResponse<object>>> CustomerExists([FromQuery] string identifier)
=> Response(await _customerService.CustomerExists(new { identifier }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpGet("customer/me")]
public async Task<ActionResult<ApiResponse<object>>> GetMyProfile()
=> Response(await _customerService.GetCusBasicDetails(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpGet("customer/me/gym")]
public async Task<ActionResult<ApiResponse<object>>> GetMyGym()
=> Response(await _customerService.GetMyGym(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpDelete("customer/me/gym")]
public async Task<ActionResult<ApiResponse<object>>> UnfollowMyGym()
=> Response(await _customerService.UnfollowMyGym(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpGet("customer/me/payments")]
public async Task<ActionResult<ApiResponse<object>>> MyPayment()
=> Response(await _customerService.MyPayment(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpPut("customer/me")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> GetMyHealth()
=> Response(await _customerService.GetCusHealthDetails(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpPut("customer/me/health")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> GetMyProgress()
=> Response(await _customerService.GetCusProgressDetails(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpGet("customer/progress")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> GetWeeklyProgress(Guid customerTargetId)
=> Response(await _customerService.GetWeeklyProgressForCustomer(new { customerTargetId }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpGet("customer/meal-plans")]
public async Task<ActionResult<ApiResponse<object>>> GetMyFollowedMealPlans()
=> Response(await _customerService.GetFollowedMealPlans(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpGet("customer/meal-plans/mine")]
public async Task<ActionResult<ApiResponse<object>>> GetMyOwnMealPlans()
=> Response(await _customerService.GetMyMealPlans(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpGet("customer/workout-plans")]
public async Task<ActionResult<ApiResponse<object>>> GetMyFollowedWorkoutPlans()
=> Response(await _customerService.GetFollowedWorkoutPlans(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpGet("customer/workout-plans/mine")]
public async Task<ActionResult<ApiResponse<object>>> GetMyOwnWorkoutPlans()
=> Response(await _customerService.GetMyWorkoutPlans(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpGet("customer/meal-plans/{mealPlanId:guid}")]
public async Task<ActionResult<ApiResponse<object>>> GetMyMealPlanById(Guid mealPlanId)
=> Response(await _customerService.GetCustomerMealPlanById(new { mealPlanId }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpGet("customer/workout-plans/{workoutPlanId:guid}")]
public async Task<ActionResult<ApiResponse<object>>> GetMyWorkoutPlanById(Guid workoutPlanId)
=> Response(await _customerService.GetCustomerWorkoutPlanById(new { workoutPlanId }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpPost("customer/meal-plans/{mealPlanId:guid}/follow")]
public async Task<ActionResult<ApiResponse<object>>> FollowMealPlan(Guid mealPlanId)
=> await RequireVerifiedAsync()
?? Response(await _customerService.FollowMealPlan(new { mealPlanId }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpPost("customer/workout-plans/{workoutPlanId:guid}/follow")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> GetMySubscription()
=> Response(await _customerService.GetMySubscription(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpGet("customer/cards")]
public async Task<ActionResult<ApiResponse<object>>> GetMyCards()
=> Response(await _customerService.GetCardDetails(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpPost("customer/cards")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> GetGyms()
=> Response(await _customerService.GetClientGyms(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpGet("customer/gyms/{clientBizId:guid}")]
public async Task<ActionResult<ApiResponse<object>>> GetGymById(Guid clientBizId)
=> Response(await _customerService.GetClientDataById(new { clientBizID = clientBizId }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpGet("customer/gyms/{clientId:guid}/plans")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> GetGymTerms(Guid clientId)
=> Response(await _customerService.GetGymTerms(new { clientId }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpPost("customer/gyms/{clientId:guid}/terms/accept")]
public async Task<ActionResult<ApiResponse<object>>> 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<IActionResult> 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<ActionResult<ApiResponse<object>>> GetMealPlanTemplates()
=> Response(await _mealService.GetMealPlanList(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpGet("meal-categories")]
public async Task<ActionResult<ApiResponse<object>>> GetMealCategories()
=> Response(await _mealService.GetMealCategoryList(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpGet("meal-plans/{mealPlanId:guid}/items")]
public async Task<ActionResult<ApiResponse<object>>> GetMealPlanItems(Guid mealPlanId)
=> Response(await _mealService.GetMealItemsByMealPlanId(new { mealPlanId }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpPost("meal-plans")]
public async Task<ActionResult<ApiResponse<object>>> CreateMealPlan([FromBody] JsonElement request)
=> await RequireVerifiedAsync()
?? Response(await _mealService.CreateCustomerMealPlan(request, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpGet("workout-plans/templates")]
public async Task<ActionResult<ApiResponse<object>>> GetWorkoutPlanTemplates()
=> Response(await _workoutService.GetWorkoutPlanList(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpGet("workout-categories")]
public async Task<ActionResult<ApiResponse<object>>> GetWorkoutCategories()
=> Response(await _workoutService.GetWorkoutCategoryList(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpPost("workout-plans")]
public async Task<ActionResult<ApiResponse<object>>> CreateWorkoutPlan([FromBody] JsonElement request)
=> await RequireVerifiedAsync()
?? Response(await _workoutService.CreateCustomerWorkout(request, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpPost("chat/meal/sessions")]
public async Task<ActionResult<ApiResponse<object>>> CreateMealChatSession([FromBody] GetOrCreateChatRequest request)
=> Response(await _chatService.GetOrCreateChatAsync(new { otherUserId = request.OtherUserId }));
[CustomMiddleware]
[TenantResolution]
[HttpPost("chat/workout/sessions")]
public async Task<ActionResult<ApiResponse<object>>> CreateWorkoutChatSession([FromBody] GetOrCreateChatRequest request)
=> Response(await _chatService.GetOrCreateChatAsync(new { otherUserId = request.OtherUserId }));
[CustomMiddleware]
[TenantResolution]
[HttpPost("chat/meal/messages")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<IActionResult> ExecuteMeal([FromBody] ApiRequest request)
{
var response = await _mealFuncHandler.Execute(request, HttpContext);
return StatusCode(response.StatusCode, response);
}
[HttpPost("workout")]
[CustomMiddleware]
[TenantResolution]
public async Task<IActionResult> ExecuteWorkout([FromBody] ApiRequest request)
{
var response = await _workoutFuncHandler.Execute(request, HttpContext);
return StatusCode(response.StatusCode, response);
}
[HttpPost("chat")]
[CustomMiddleware]
[TenantResolution]
public async Task<IActionResult> ExecuteChat([FromBody] ApiRequest request)
{
var response = await _chatFuncHandler.Execute(request);
return StatusCode(response.StatusCode, response);
}
[HttpPost("initChat")]
[CustomMiddleware]
[TenantResolution]
public async Task<IActionResult> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> LikeFeedPost(Guid postId)
=> Response(await _feedService.LikeAsync(new { postId }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpPost("feed")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> GetTickets()
=> Response(await _ticketService.GetTicketByUserId(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpGet("tickets/{ticketId:guid}")]
public async Task<ActionResult<ApiResponse<object>>> GetTicketById(Guid ticketId)
=> Response(await _ticketService.GetTicketById(new { ticketId }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpGet("ticket-categories")]
public async Task<ActionResult<ApiResponse<object>>> GetTicketCategories()
=> Response(await _ticketService.GetTicketCategoryList(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpPost("tickets")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> CloseTicket(Guid ticketId)
=> Response(await _ticketService.CloseTicket(new { ticketId }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpPost("tickets/{ticketId:guid}/feedback")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> UploadDocument([FromBody] JsonElement request)
=> Response(await _docService.UploadDocument(request, HttpContext));
[HttpPost("ticket")]
[CustomMiddleware]
[TenantResolution]
public async Task<IActionResult> ExecuteTicket([FromBody] ApiRequest request)
{
var response = await _ticketFuncHandler.Execute(request, HttpContext);
return StatusCode(response.StatusCode, response);
}
[HttpPost("document")]
[CustomMiddleware]
[TenantResolution]
public async Task<IActionResult> 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<ActionResult<ApiResponse<object>>> GetMyNotifications()
=> Response(await _customerService.GetMyNotifications(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpPatch("customer/notifications/{notificationId:guid}/read")]
public async Task<ActionResult<ApiResponse<object>>> MarkNotificationRead(Guid notificationId)
=> Response(await _customerService.MarkNotificationRead(notificationId, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpPut("customer/push-token")]
public async Task<ActionResult<ApiResponse<object>>> UpdatePushToken([FromBody] UpdatePushTokenRequest request)
=> Response(await _customerService.UpdatePushToken(new { pushTokenId = request.PushTokenId }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpPut("customer/profile-image")]
public async Task<ActionResult<ApiResponse<object>>> UpdateProfileImage([FromBody] UpdateProfileImageRequest request)
=> Response(await _customerService.UpdateProfileImage(new { documentId = request.DocumentId }, HttpContext));
// Serves an uploaded document by id (mobile builds <base>/docService/{id} for assets).
[HttpGet("docService/{docId}")]
[AllowAnonymous]
public async Task<IActionResult> 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}");
}
}
}
}
+154
View File
@@ -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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<IActionResult> 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<ActionResult<ApiResponse<object>>> 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<object>(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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> GetInvoiceByPaymentToken(string token)
=> Response(await _paymentService.GetInvoiceByPaymentToken(token));
[HttpPost("pay/{token}/charge")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> GetSubscriptionPayments(Guid id)
=> Response(await _paymentService.GetSubscriptionPayments(new { subscriptionId = id }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("customer-payments")]
[HttpPost("subscriptions/{id:guid}/payments")]
public async Task<ActionResult<ApiResponse<object>>> RecordSubscriptionPayment(Guid id, [FromBody] RecordSubscriptionPaymentRequest request)
{
var payload = new Dictionary<string, object>
{
["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; }
}
}
}
+100
View File
@@ -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<ActionResult<ApiResponse<object>>> GetAllFacilities([FromQuery] bool includeInactive = false)
=> Response(await _facilityService.GetAllFacilities(new { includeInactive }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("bookings")]
[HttpPost("")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> UpdateFacility(Guid id, [FromBody] UpdateFacilityRequest request)
{
var payload = new Dictionary<string, object> { ["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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> CancelFacilitySession(Guid id)
=> Response(await _facilityService.CancelFacilitySession(new { facilitySessionId = id }, HttpContext));
}
}
+67
View File
@@ -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<ActionResult<ApiResponse<List<GymRoleDto>>>> GetRoles()
=> Response(await _gymRoleService.GetRolesAsync(HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpPost("")]
public async Task<ActionResult<ApiResponse<GymRoleDto>>> CreateRole([FromBody] CreateGymRoleRequest request)
=> Response(await _gymRoleService.CreateRoleAsync(request, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpPut("{id:guid}")]
public async Task<ActionResult<ApiResponse<GymRoleDto>>> UpdateRole(Guid id, [FromBody] UpdateGymRoleRequest request)
=> Response(await _gymRoleService.UpdateRoleAsync(id, request, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpDelete("{id:guid}")]
public async Task<ActionResult<ApiResponse<object>>> DeleteRole(Guid id)
{
await _gymRoleService.DeleteRoleAsync(id, HttpContext);
return Response<object>(new { deleted = true });
}
[CustomMiddleware]
[TenantResolution]
[HttpPut("{id:guid}/permissions")]
public async Task<ActionResult<ApiResponse<GymRoleDto>>> 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<ActionResult<ApiResponse<object>>> AssignStaffRole(Guid staffId, [FromBody] AssignStaffRoleRequest request)
{
await _gymRoleService.AssignStaffRoleAsync(staffId, request, HttpContext);
return Response<object>(new { assigned = true });
}
}
}
+32
View File
@@ -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<ActionResult<ApiResponse<NavResponseDto>>> GetNav()
{
return Response(await _navigationService.GetNavigationAsync(HttpContext));
}
}
}
@@ -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<ActionResult<ApiResponse<List<PaymentTrackOption>>>> GetPaymentTracks()
=> Response(await _payments.GetTrackOptionsAsync());
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("payment-gateway")]
[HttpPost("payments/track-selection")]
public async Task<ActionResult<ApiResponse<PaymentTrackSelectionResult>>> SelectTrack([FromBody] SelectPaymentTrackRequest request)
=> Response(await _payments.SelectTrackAsync(HttpContext, request));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("payment-gateway")]
[HttpGet("payments/gateways")]
public async Task<ActionResult<ApiResponse<List<GatewayDefinitionDto>>>> GetAvailableGateways()
=> Response(await _payments.GetAvailableGatewaysAsync(HttpContext.RequestAborted));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("payment-gateway")]
[HttpPost("payments/kyc")]
public async Task<ActionResult<ApiResponse<ClientPayoutKycDto>>> SubmitKyc(
[FromBody] SubmitGatewayOnboardingRequest request)
=> Response(await _payments.SubmitKycAsync(
HttpContext, request, HttpContext.RequestAborted));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("payment-gateway")]
[HttpGet("payments/kyc/status")]
public async Task<ActionResult<ApiResponse<GatewayOnboardingStatusDto>>> GetKycStatus()
=> Response(await _payments.GetKycStatusAsync(HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("payment-gateway")]
[HttpGet("payments/wallet")]
public async Task<ActionResult<ApiResponse<WalletDto>>> GetWallet()
=> Response(await _payments.GetWalletAsync(HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("payment-gateway")]
[HttpGet("payments/payout-schedule")]
public async Task<ActionResult<ApiResponse<PayoutScheduleDto>>> GetPayoutSchedule()
=> Response(await _payments.GetPayoutScheduleAsync(HttpContext));
}
}
+71
View File
@@ -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<ActionResult<ApiResponse<object>>> GetAllPlaces([FromQuery] bool includeInactive = false)
=> Response(await _placeService.GetAllPlaces(new { includeInactive }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("memberships")]
[HttpPost("")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> UpdatePlace(Guid id, [FromBody] UpdatePlaceRequest request)
{
var payload = new Dictionary<string, object> { ["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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> AssignDevice(Guid id, [FromBody] AssignPlaceDeviceRequest request)
=> Response(await _placeService.AssignDevice(new { placeId = id, deviceId = request.DeviceId }, HttpContext));
}
}
@@ -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<ActionResult<ApiResponse<object>>> GetUnapprovedInvoices()
=> Response(await _approvalService.GetUnapprovedInvoicesAsync(HttpContext));
[HttpPost("{invoiceId:guid}/approve")]
public async Task<ActionResult<ApiResponse<object>>> Approve(Guid invoiceId)
=> Response(await _approvalService.ApproveAsync(invoiceId, HttpContext));
[HttpPost("{invoiceId:guid}/change-plan")]
public async Task<ActionResult<ApiResponse<object>>> ChangePlan(Guid invoiceId, [FromBody] ChangeRenewalPlanRequest request)
=> Response(await _approvalService.ChangePlanAsync(invoiceId, request.NewCustomerPlanId, HttpContext));
[HttpPost("{invoiceId:guid}/reject")]
public async Task<ActionResult<ApiResponse<object>>> Reject(Guid invoiceId)
=> Response(await _approvalService.RejectAsync(invoiceId, HttpContext));
}
}
+63
View File
@@ -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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> GetBalance(string rfidTagId)
=> Response(await _timeBankCardService.GetBalance(new { rfidTagId }));
[CustomMiddleware]
[TenantResolution]
[HttpGet("api/app/time-bank-cards/mine")]
public async Task<ActionResult<ApiResponse<object>>> GetMyCards()
=> Response(await _timeBankCardService.GetMyCards(new { }, HttpContext));
}
}
+31
View File
@@ -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<ActionResult<ApiResponse<SubscriptionChargeRunResult>>> RunSubscriptionCharges()
{
return Response(await _platformBilling.RunAndChargeSubscriptionBillingAsync(HttpContext.RequestAborted));
}
}
}
+68
View File
@@ -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)
+47
View File
@@ -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; }
}
/// <summary>
/// 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.
/// </summary>
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; }
}
}
+76
View File
@@ -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<AgentAttendanceEvent> 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<DeviceInventoryPerson> 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<AgentDeviceHealthReport> 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; }
}
}
+9
View File
@@ -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; }
}
}
+14
View File
@@ -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; }
}
}
+9
View File
@@ -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; }
}
}
+10
View File
@@ -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; }
}
}
+31
View File
@@ -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; }
/// <summary>Only populated in the response right after booking creation -- the
/// guest's receipt. Never returned by list/lookup endpoints afterward.</summary>
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; }
/// <summary>A public, unauthenticated link to this booking's confirmation page --
/// copy-paste-and-share receipt. Works for both member and guest bookings.</summary>
public string? ShareUrl { get; set; }
}
}
+44
View File
@@ -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; }
}
}
+15
View File
@@ -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<ClientInvoiceItemDto> Items { get; set; } = new List<ClientInvoiceItemDto>();
}
}
@@ -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; }
}
}
@@ -0,0 +1,7 @@
namespace Fitness_core_BE.DTOs.ClientInvoice
{
public class CreateClientInvoiceRequest
{
public Guid SubscriptionId { get; set; }
}
}
@@ -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;
}
}
+14
View File
@@ -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;
}
}
+11
View File
@@ -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; }
}
}
+4
View File
@@ -0,0 +1,4 @@
namespace Fitness_core_BE.DTOs.Customer
{
public record FollowedPlanDto(Guid PlanId, string? Name, bool IsFollowing, DateTime? CompletedDate);
}
+56
View File
@@ -0,0 +1,56 @@
using Fitness_core_BE.Models;
namespace Fitness_core_BE.DTOs.Customer
{
/// <summary>
/// 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.
/// </summary>
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; }
/// <summary>False when the member has been unassigned from their plan but is still a member of the gym.</summary>
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; }
/// <summary>Face-photo enrolment on the gym's access terminals.</summary>
public bool HasFacePhoto { get; set; }
/// <summary>"None" | "Pending" | "Synced" | "Failed".</summary>
public string FacePhotoStatus { get; set; } = "None";
public string? FacePhotoError { get; set; }
}
}
+14
View File
@@ -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; }
}
}
@@ -0,0 +1,7 @@
namespace Fitness_core_BE.DTOs.CustomerInvoice
{
public class CreateCustomerInvoiceRequest
{
public Guid SubscriptionId { get; set; }
}
}
@@ -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<CustomerInvoiceItemDto> Items { get; set; } = new List<CustomerInvoiceItemDto>();
}
}
@@ -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; }
}
}
@@ -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;
}
}
@@ -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; }
/// <summary>Required when DurationMode is "Rolling" (validated in the service).</summary>
[Range(1, int.MaxValue, ErrorMessage = "Duration must be at least 1 day")]
public int? DurationInDays { get; set; }
/// <summary>"Individual" (default) | "Group".</summary>
public string? PlanScope { get; set; }
/// <summary>Required >= 2 when PlanScope is "Group".</summary>
public int? MemberLimit { get; set; }
/// <summary>"Rolling" (default) | "FixedRange".</summary>
public string? DurationMode { get; set; }
public DateTime? ValidFrom { get; set; }
public DateTime? ValidTo { get; set; }
public bool? AllowSplitPayments { get; set; }
/// <summary>Number of mostly-equal installments; defaults to plan duration in months when split is enabled.</summary>
public int? InstallmentCount { get; set; }
/// <summary>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").</summary>
[Range(1, int.MaxValue, ErrorMessage = "SessionsPerCycle must be at least 1 when set")]
public int? SessionsPerCycle { get; set; }
/// <summary>"NoRollover" (default) | "GracePeriod". Only meaningful when SessionsPerCycle is set.</summary>
public string? SessionRolloverPolicy { get; set; }
/// <summary>Required (>= 1) when SessionRolloverPolicy is "GracePeriod".</summary>
[Range(0, int.MaxValue, ErrorMessage = "RolloverGraceDays must be zero or greater")]
public int? RolloverGraceDays { get; set; }
/// <summary>"Prepaid" (default, pay before the cycle starts) | "Postpaid" (use first,
/// invoice at cycle end, access suspended if unpaid past due). Asked once per package.</summary>
public string? BillingTiming { get; set; }
/// <summary>Places this plan grants access to. Null/omitted or empty = unrestricted
/// (reaches every active access device, today's behavior).</summary>
public List<Guid>? PlaceIds { get; set; }
}
}
+33
View File
@@ -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<Guid> PlaceIds { get; set; } = new();
public List<string> PlaceNames { get; set; } = new();
}
}
@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
namespace Fitness_core_BE.DTOs.CustomerPlan
{
/// <summary>
/// Body of PATCH client/plans/{id}/status -- activate or deactivate a membership plan
/// without resending its configuration.
/// </summary>
public class SetCustomerPlanStatusRequest
{
[Required]
public bool IsActive { get; set; }
}
}
@@ -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; }
/// <summary>Replaces this plan's place set when provided (empty array clears it back
/// to unrestricted). Omit/null to leave the plan's places unchanged.</summary>
public List<Guid>? PlaceIds { get; set; }
}
}
@@ -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; }
}
}
@@ -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<CustomerSubItemDto>? 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"
}
}
@@ -0,0 +1,62 @@
namespace Fitness_core_BE.DTOs.CustomerSubscription
{
/// <summary>Full picture of one subscription: plan config, members, money, schedule.</summary>
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<SubscriptionMemberDto> Members { get; set; } = new();
public List<SubscriptionInstallmentDto> 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; }
/// <summary>False when unassigned from the plan but still a member of the gym.</summary>
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";
}
}
+9
View File
@@ -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; }
}
}
+25
View File
@@ -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; }
}
}
+51
View File
@@ -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;
/// <summary>"Gym" | "Pool" | "Court" | "Class".</summary>
[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; }
/// <summary>Overrides the facility's default capacity for this specific session.</summary>
public int? Capacity { get; set; }
}
}
+40
View File
@@ -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<CategoryTotalDto> IncomeByCategory { get; set; } = new();
public List<CategoryTotalDto> ExpenseByCategory { get; set; } = new();
public List<LedgerEntryDto> Entries { get; set; } = new();
}
}
+16
View File
@@ -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; }
}
}
+11
View File
@@ -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; }
}
}
+19
View File
@@ -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; }
}
}
+22
View File
@@ -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<MealDto> MealItems { get; set; } = new List<MealDto>();
public List<CustomerMealDto> AssignedCustomers { get; set; } = new List<CustomerMealDto>();
public decimal? TotalCalories { get; set; }
public decimal? TotalProtein { get; set; }
public decimal? TotalCarbohydrates { get; set; }
public decimal? TotalFats { get; set; }
}
}
+17
View File
@@ -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<CustomerMealDto> AssignedCustomers { get; set; } = new List<CustomerMealDto>();
}
}
+65
View File
@@ -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;
}
}
+15
View File
@@ -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";
}
}
+72
View File
@@ -0,0 +1,72 @@
namespace Fitness_core_BE.DTOs.Mobile
{
/// <summary>Self-service profile update (mobile). Distinct from DTOs/Customer/UpdateCustomerRequest,
/// which is the client-managed shape used by ClientController.</summary>
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; }
}
/// <summary>Updates the customer's latest height and/or weight measurement.</summary>
public class UpdateProgressRequest
{
public decimal? Height { get; set; }
public decimal? Weight { get; set; }
public DateTime? ProgressDate { get; set; }
}
/// <summary>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).</summary>
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; }
/// <summary>Must be explicitly true — this is the member's auto-deduct consent.</summary>
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;
}
/// <summary>documentId comes from POST /customer/documents' upload response — the client
/// uploads the picked image first, then links it here as the profile picture.</summary>
public class UpdateProfileImageRequest
{
public int DocumentId { get; set; }
}
}
+7
View File
@@ -0,0 +1,7 @@
namespace Fitness_core_BE.DTOs.Mobile
{
public class AcceptGymTermsRequest
{
public int Version { get; set; }
}
}
+20
View File
@@ -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; }
}
}
@@ -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<PaymentListItemResult> Items { get; set; } = new();
}
}
@@ -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;
}
}
@@ -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<WalletLedgerEntryDto> 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);
}
+15
View File
@@ -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; }
}
}
+32
View File
@@ -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; }
}
}
@@ -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<TransactionHistoryItemDto> Items { get; set; } = new();
}
}
+137
View File
@@ -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<ClientInvoiceItemDto> 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; }
}
}
+47
View File
@@ -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<NavItemDto> 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<string> 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<string> NavKeys { get; set; } = new();
}
public class AssignStaffRoleRequest
{
// Nullable to support unassigning a staff member's role.
public Guid? GymRoleId { get; set; }
}
}
+27
View File
@@ -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; }
}
}
+27
View File
@@ -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; }
}
}
+30
View File
@@ -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; }
}
}
+24
View File
@@ -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; }
}
}
+21
View File
@@ -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; }
}
}
+11
View File
@@ -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; }
}
}
+48
View File
@@ -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<TicketMessageDto> Messages { get; set; } = new List<TicketMessageDto>();
}
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; }
}
}
+31
View File
@@ -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; }
}
}
+24
View File
@@ -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; }
/// <summary>True right after a tap-in swipe (session open, clock running); false after
/// tap-out or when the card has no open session.</summary>
public bool SessionOpen { get; set; }
public DateTime? SessionStartedAt { get; set; }
public string? SessionFacilityName { get; set; }
/// <summary>Only populated in the response to a tap-out swipe.</summary>
public int? MinutesDeductedThisSwipe { get; set; }
}
}
+33
View File
@@ -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; }
/// <summary>Ties the card to a member's own account; omit for a walk-in-only card.</summary>
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; }
}
}
+9
View File
@@ -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; }
}
}
+16
View File
@@ -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; }
}
}
+11
View File
@@ -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; }
}
}
+17
View File
@@ -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; }
}
}
+23
View File
@@ -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<WorkoutExerciseDto> WorkoutExercises { get; set; } = new List<WorkoutExerciseDto>();
public int TotalExercises { get; set; }
public int CompletedExercises { get; set; }
public bool IsCompleted { get; set; }
}
}
+18
View File
@@ -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<WorkoutItemDto> WorkoutItems { get; set; } = new List<WorkoutItemDto>();
public List<CustomerWorkoutDto> AssignedCustomers { get; set; } = new List<CustomerWorkoutDto>();
}
}
+17
View File
@@ -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<CustomerWorkoutDto> AssignedCustomers { get; set; } = new List<CustomerWorkoutDto>();
}
}

Some files were not shown because too many files have changed in this diff Show More