first commit
This commit is contained in:
@@ -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
@@ -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
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Fitness_core_BE.AI
|
||||
{
|
||||
public interface IAiClient
|
||||
{
|
||||
Task<string> GenerateJsonAsync(string systemPrompt, object payload);
|
||||
}
|
||||
}
|
||||
@@ -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)!;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)!;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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! });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace Fitness_core_BE.DTOs.Customer
|
||||
{
|
||||
public record FollowedPlanDto(Guid PlanId, string? Name, bool IsFollowing, DateTime? CompletedDate);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Fitness_core_BE.DTOs.Mobile
|
||||
{
|
||||
public class AcceptGymTermsRequest
|
||||
{
|
||||
public int Version { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user