using System.Text.Json; using Fitness_core_BE.DTOs.AccessDevice; using Fitness_core_BE.DTOs.CustomerPlan; using Fitness_core_BE.DTOs.CustomerSubscription; using Fitness_core_BE.DTOs.Ledger; using Fitness_core_BE.DTOs.Mobile; using Fitness_core_BE.Exceptions; using Fitness_core_BE.Helpers; using Fitness_core_BE.Interfaces.Services; using Fitness_core_BE.Models.IOs; using Microsoft.AspNetCore.Mvc; using static Fitness_core_BE.Helpers.JwtMiddleware; using static Fitness_core_BE.Helpers.ApiResponseHelper; namespace Fitness_core_BE.Controllers { [ApiController] [Route("api/client")] public class ClientController : ControllerBase { private readonly IClientService _clientService; private readonly IStaffService _staffService; private readonly IClientStatService _clientStatService; private readonly IAttendanceService _attendanceService; private readonly ICustomerPlanService _customerPlanService; private readonly IWorkoutService _workoutService; private readonly IMealService _mealService; private readonly IFeedService _feedService; private readonly ITicketService _ticketService; private readonly ICustomerSubscriptionService _customerSubscriptionService; private readonly UserRoleHelper _userRoleHelper; private readonly IAccessDeviceService _accessDeviceService; private readonly IAdminClientDirectoryService _clientDirectoryService; private readonly IProfitLossService _profitLossService; public ClientController( IClientService clientService, IStaffService staffService, IClientStatService clientStatService, IAttendanceService attendanceService, ICustomerPlanService customerPlanService, IWorkoutService workoutService, IMealService mealService, IFeedService feedService, ITicketService ticketService, ICustomerSubscriptionService customerSubscriptionService, UserRoleHelper userRoleHelper, IAccessDeviceService accessDeviceService, IAdminClientDirectoryService clientDirectoryService, IProfitLossService profitLossService) { _clientService = clientService; _staffService = staffService; _clientStatService = clientStatService; _attendanceService = attendanceService; _customerPlanService = customerPlanService; _workoutService = workoutService; _mealService = mealService; _feedService = feedService; _ticketService = ticketService; _customerSubscriptionService = customerSubscriptionService; _userRoleHelper = userRoleHelper; _accessDeviceService = accessDeviceService; _clientDirectoryService = clientDirectoryService; _profitLossService = profitLossService; } // ---------- C. Own profile & business ---------- [CustomMiddleware] [TenantResolution] [HttpGet("me")] public async Task>> GetMyProfile() => Response(await _clientService.GetClientById(new { }, HttpContext)); [CustomMiddleware] [TenantResolution] [HttpGet("packages")] public async Task>> GetPackages() => Response(await _clientService.GetPackageList(new { }, HttpContext)); // ---------- C. Own profile & business ---------- [CustomMiddleware] [TenantResolution] [RequiresPermission("settings")] [HttpPut("gym-profile")] public async Task>> UpdateGymProfile( [FromBody] UpdateGymProfileRequest request) => Response(await _clientService.UpdateGymProfile(request, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("settings")] [HttpPost("gym-profile/logo")] [RequestSizeLimit(6_000_000)] [Consumes("multipart/form-data")] public async Task>> UploadGymLogo([FromForm] IFormFile logo) => Response(await _clientService.UploadGymLogo(logo, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("settings")] [HttpGet("gym-profile/logo")] public async Task>> GetGymLogo() => Response(await _clientService.GetGymLogo(HttpContext)); // Gym-specific Terms & Conditions authoring — publishing deactivates the previous // version (customers keep their acceptance record pointing at the version they saw). [CustomMiddleware] [TenantResolution] [RequiresPermission("settings")] [HttpGet("gym-profile/terms")] public async Task>> GetMyGymTerms() => Response(await _clientService.GetMyGymTerms(new { }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("settings")] [HttpPut("gym-profile/terms")] public async Task>> PublishGymTerms([FromBody] PublishGymTermsRequest request) => Response(await _clientService.PublishGymTerms(new { content = request.Content }, HttpContext)); // Change the owner's own phone/email: send an OTP to the NEW value, then verify it. // On verify the new value is written to the auth DB and mirrored onto the Client record. [CustomMiddleware] [TenantResolution] [RequiresPermission("settings")] [HttpPost("me/contact/otp")] public async Task>> SendContactOtp([FromBody] SendContactOtpRequest request) => Response(await _clientService.SendContactOtp(new { identifier = request.Identifier }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("settings")] [HttpPost("me/contact/verify")] public async Task>> VerifyContactOtp([FromBody] VerifyContactOtpRequest request) => Response(await _clientService.VerifyContactOtp(new { type = request.Type, identifier = request.Identifier, referenceNumber = request.ReferenceNumber, otpCode = request.OtpCode }, HttpContext)); // ---------- D. Customer management ---------- [CustomMiddleware] [TenantResolution] [RequiresPermission("members")] [HttpGet("customers")] public async Task>> GetCustomers() => Response(await _clientService.GetCustomersByClientId(new { }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("members")] [HttpGet("customers/{customerId:guid}")] public async Task>> GetCustomer(Guid customerId) => Response(await _clientService.GetCustomerDetailsById(new { customerId }, HttpContext)); // Aggregated profile for the member "More Info" view (targets, progress, followed plans). [CustomMiddleware] [TenantResolution] [RequiresPermission("members")] [HttpGet("customers/{customerId:guid}/full")] public async Task>> GetCustomerFull(Guid customerId) => Response(await _clientService.GetCustomerFullDetails(new { customerId }, HttpContext)); // Saved cards for this member (used to offer the auto-charge toggle on their subscription). [CustomMiddleware] [TenantResolution] [RequiresPermission("members")] [HttpGet("customers/{customerId:guid}/cards")] public async Task>> GetCustomerCards(Guid customerId) => Response(await _clientService.GetCustomerCards(new { customerId }, HttpContext)); // Password-gated: returns medical details only when the owner's password is verified. [CustomMiddleware] [TenantResolution] [RequiresPermission("members")] [HttpPost("customers/{customerId:guid}/medical")] public async Task>> GetCustomerMedical(Guid customerId, [FromBody] MedicalUnlockRequest request) => Response(await _clientService.GetCustomerMedical(new { customerId, password = request.Password }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("members")] [HttpPost("customers")] public async Task>> CreateCustomer([FromBody] CreateCustomerWithPlansRequest request) => Response(await _clientService.CreateCustomerWithPlans(new { userName = request.UserName, email = request.Email, mobileNumber = request.MobileNumber, fullname = request.Fullname, gender = request.Gender, birthday = request.Birthday, nic = request.Nic, workoutPlanIds = request.WorkoutPlanIds, mealPlanIds = request.MealPlanIds, customerPlanId = request.CustomerPlanId, onboardDate = request.OnboardDate, chargePayment = request.ChargePayment, confirmLink = request.ConfirmLink, discountType = request.DiscountType, discountValue = request.DiscountValue }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("members")] [HttpPut("customers/{customerId:guid}")] public async Task>> UpdateCustomer(Guid customerId, [FromBody] UpdateCustomerByClientRequest request) => Response(await _clientService.UpdateCustomerByClient(new { customerId, userName = request.UserName, email = request.Email, phoneNumber = request.MobileNumber, fullname = request.Fullname, gender = request.Gender, birthday = request.Birthday, nic = request.Nic }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("members")] [HttpDelete("customers/{customerId:guid}")] public async Task>> DeleteCustomer(Guid customerId) => Response(await _clientService.DeleteCustomerByClient(new { customerId }, HttpContext)); // "Parent" path: assigns a brand new plan/subscription to a customer who was created // without one, making them the primary member. ("Child" path — joining an existing // group subscription — is POST subscriptions/{subscriptionId}/members below.) [CustomMiddleware] [TenantResolution] [RequiresPermission("members")] [HttpPost("customers/{customerId:guid}/subscription")] public async Task>> AssignCustomerPlan(Guid customerId, [FromBody] AssignCustomerPlanRequest request) => Response(await _clientService.AssignPlanToExistingCustomer(customerId, new { customerPlanId = request.CustomerPlanId, expiryDate = request.ExpiryDate, onboardDate = request.OnboardDate, chargePayment = request.ChargePayment, discountType = request.DiscountType, discountValue = request.DiscountValue }, HttpContext)); // Switches the customer onto a different Individual-scope plan — distinct from // AssignCustomerPlan above, which is only for a customer with no active subscription // yet. The customer's current plan may be Individual or Group; if they're the primary // member of a Group subscription, the whole group is dropped to unassigned in the // process, otherwise only their own membership row is replaced. [CustomMiddleware] [TenantResolution] [RequiresPermission("members")] [HttpPut("customers/{customerId:guid}/subscription/plan")] public async Task>> ChangeMembershipPlan(Guid customerId, [FromBody] ChangeMembershipPlanRequest request) => Response(await _clientService.ChangeCustomerMembershipPlan(customerId, request.CustomerPlanId, HttpContext)); // Drops the customer's current plan back to unassigned without assigning a new one. If // they're the primary member of a Group subscription, every member on it is unassigned. [CustomMiddleware] [TenantResolution] [RequiresPermission("members")] [HttpDelete("customers/{customerId:guid}/subscription")] public async Task>> UnassignMembership(Guid customerId) => Response(await _clientService.UnassignCustomerMembership(customerId, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("members")] [HttpGet("access-devices")] public async Task>> GetAccessDevices() { var clientId = await _userRoleHelper.GetClientId( DataExtractor.GetUserIdFromContext(HttpContext)); return Response(await _clientDirectoryService.GetDevicesAsync(clientId)); } // Fingerprints enrolled directly on the physical device (by staff) with no system // Customer linked yet — surfaced so the client portal can offer them in a dropdown. [CustomMiddleware] [TenantResolution] [RequiresPermission("members")] [HttpGet("customers/unlinked-fingerprints")] public async Task>>> GetUnlinkedFingerprints() { var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(HttpContext)); var enrollments = await _accessDeviceService.GetUnlinkedFingerprintsAsync(clientId); return Response(enrollments.Select(e => new UnlinkedFingerprintDto { Id = e.Id, DeviceUserId = e.DeviceUserId, DeviceEmployeeNo = string.IsNullOrWhiteSpace(e.DeviceEmployeeNo) ? e.DeviceUserId.ToString() : e.DeviceEmployeeNo, DeviceReportedName = e.DeviceReportedName, DeviceUserType = e.DeviceUserType, FingerprintCount = e.FingerprintCount, DeviceId = e.FKDeviceId, DeviceName = e.AccessDevice?.DeviceName, DiscoveredAt = e.DiscoveredAt }).ToList()); } [CustomMiddleware] [TenantResolution] [RequiresPermission("members")] [HttpPost("customers/{customerId:guid}/link-fingerprint")] public async Task>> LinkFingerprint(Guid customerId, [FromBody] LinkFingerprintRequest request) { var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(HttpContext)); var profile = await _accessDeviceService.LinkFingerprintAsync(clientId, customerId, request.UnlinkedEnrollmentId); return Response(new { accessProfileId = profile.AccessProfileId, accessNumber = profile.DeviceBindings .OrderByDescending(b => b.LastSyncedAt) .FirstOrDefault()?.DeviceEmployeeNo ?? $"HF{profile.AccessProfileId}", name = profile.DisplayName }); } // Face photo enrolment. The JPEG rides the existing DeviceCommand queue to the // on-prem agent, which pushes it into the Hikvision face library; the payload is // purged once a terminal confirms, so the image is never stored here. [CustomMiddleware] [TenantResolution] [RequiresPermission("members")] [HttpPost("customers/{customerId:guid}/face-photo")] public async Task>> SetCustomerFacePhoto( Guid customerId, [FromBody] SetFacePhotoRequest request) { var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(HttpContext)); return Response(await _accessDeviceService.SetCustomerFacePhotoAsync( clientId, customerId, request.ImageBase64)); } // Manual lock/unlock (req 7) — reuses the existing BlackListMember/WhiteListMember // device-command pipeline (AccessDeviceService); also used automatically by OverdueLockJob. [CustomMiddleware] [TenantResolution] [RequiresPermission("members")] [HttpPost("customers/{customerId:guid}/lock")] public async Task>> LockCustomer(Guid customerId) { var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(HttpContext)); var status = await _accessDeviceService.SetCustomerAccessAsync( clientId, customerId, enabled: false, reason: "Manual"); return Response(status); } [CustomMiddleware] [TenantResolution] [RequiresPermission("members")] [HttpPost("customers/{customerId:guid}/unlock")] public async Task>> UnlockCustomer(Guid customerId) { var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(HttpContext)); var status = await _accessDeviceService.SetCustomerAccessAsync( clientId, customerId, enabled: true, reason: null); return Response(status); } // Auto-lock-after-non-payment settings. [CustomMiddleware] [TenantResolution] [RequiresPermission("members")] [HttpGet("settings/lock-policy")] public async Task>> GetLockPolicy() => Response(await _clientService.GetLockPolicy(HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("members")] [HttpPut("settings/lock-policy")] public async Task>> UpdateLockPolicy([FromBody] UpdateLockPolicyRequest request) => Response(await _clientService.UpdateLockPolicy(request.LockAfterDaysOverdue, HttpContext)); // Basic Profit & Loss / simple ledger. [CustomMiddleware] [TenantResolution] [RequiresPermission("reports")] [HttpGet("reports/profit-loss")] public async Task>> GetProfitLoss([FromQuery] DateTime from, [FromQuery] DateTime to) => Response(await _profitLossService.GetProfitLossAsync(from, to, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("reports")] [HttpGet("ledger-entries")] public async Task>>> GetLedgerEntries([FromQuery] DateTime from, [FromQuery] DateTime to) => Response(await _profitLossService.GetManualEntriesAsync(from, to, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("reports")] [HttpPost("ledger-entries")] public async Task>> AddLedgerEntry([FromBody] AddLedgerEntryRequest request) => Response(await _profitLossService.AddManualEntryAsync(request, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("reports")] [HttpDelete("ledger-entries/{id:guid}")] public async Task>> DeleteLedgerEntry(Guid id) { await _profitLossService.DeleteManualEntryAsync(id, HttpContext); return Response(new { deleted = true }); } // ---------- E. Staff management ---------- [CustomMiddleware] [TenantResolution] [RequiresPermission("staff")] [HttpGet("staff")] public async Task>> GetStaff() { var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(HttpContext)); return Response(await _staffService.GetStaffByClientId(new { clientId }, HttpContext)); } [CustomMiddleware] [TenantResolution] [RequiresPermission("staff")] [HttpGet("staff/{staffId:guid}")] public async Task>> GetStaffMember(Guid staffId) => Response(await _staffService.GetStaffById(new { staffId }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("staff")] [HttpPost("staff")] public async Task>> CreateStaff([FromBody] CreateStaffRequest request) => Response(await _staffService.CreateStaff(new { userName = request.UserName, email = request.Email, phoneNumber = request.PhoneNumber, staffPosition = request.StaffPosition, department = request.Department, hireDate = request.HireDate, gymRoleId = request.GymRoleId }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("staff")] [HttpPut("staff/{staffId:guid}")] public async Task>> UpdateStaff(Guid staffId, [FromBody] UpdateStaffRequest request) => Response(await _staffService.UpdateStaff(new { staffId, userName = request.UserName, email = request.Email, phoneNumber = request.PhoneNumber, staffPosition = request.StaffPosition, department = request.Department }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("staff")] [HttpPost("staff/{staffId:guid}/link-fingerprint")] public async Task>> LinkStaffFingerprint( Guid staffId, [FromBody] LinkFingerprintRequest request) { var clientId = await _userRoleHelper.GetClientId( DataExtractor.GetUserIdFromContext(HttpContext)); var profile = await _accessDeviceService.LinkStaffFingerprintAsync( clientId, staffId, request.UnlinkedEnrollmentId); return Response(new { accessProfileId = profile.AccessProfileId, accessNumber = profile.DeviceBindings .OrderByDescending(b => b.LastSyncedAt) .FirstOrDefault()?.DeviceEmployeeNo ?? $"HF{profile.AccessProfileId}", name = profile.DisplayName }); } [CustomMiddleware] [TenantResolution] [RequiresPermission("staff")] [HttpPost("staff/{staffId:guid}/lock")] public async Task>> LockStaff(Guid staffId) { var clientId = await _userRoleHelper.GetClientId( DataExtractor.GetUserIdFromContext(HttpContext)); return Response(await _accessDeviceService.SetStaffAccessAsync( clientId, staffId, enabled: false, reason: "Manual")); } [CustomMiddleware] [TenantResolution] [RequiresPermission("staff")] [HttpPost("staff/{staffId:guid}/unlock")] public async Task>> UnlockStaff(Guid staffId) { var clientId = await _userRoleHelper.GetClientId( DataExtractor.GetUserIdFromContext(HttpContext)); return Response(await _accessDeviceService.SetStaffAccessAsync( clientId, staffId, enabled: true, reason: null)); } [CustomMiddleware] [TenantResolution] [RequiresPermission("staff")] [HttpDelete("staff/{staffId:guid}")] public async Task>> DeleteStaff(Guid staffId, [FromQuery] bool softDelete = true) => Response(await _staffService.DeleteStaff(new { staffId, hardDelete = !softDelete }, HttpContext)); // Basic HRM/payroll: pay rate + payment history per staff member. [CustomMiddleware] [TenantResolution] [RequiresPermission("staff")] [HttpGet("staff/{staffId:guid}/pay-rate")] public async Task>> GetStaffPayRate(Guid staffId) => Response(await _staffService.GetPayRate(staffId, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("staff")] [HttpPut("staff/{staffId:guid}/pay-rate")] public async Task>> UpsertStaffPayRate(Guid staffId, [FromBody] Fitness_core_BE.DTOs.Staff.UpsertStaffPayRateRequest request) => Response(await _staffService.UpsertPayRate(staffId, request.RateType, request.Amount, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("staff")] [HttpGet("staff/{staffId:guid}/payments")] public async Task>> GetStaffPayments(Guid staffId) => Response(await _staffService.GetPayments(staffId, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("staff")] [HttpPost("staff/{staffId:guid}/payments")] public async Task>> RecordStaffPayment(Guid staffId, [FromBody] Fitness_core_BE.DTOs.Staff.RecordStaffPaymentRequest request) => Response(await _staffService.RecordPayment(staffId, request.Amount, request.Note, HttpContext)); // ---------- G. Dashboard stats ---------- [CustomMiddleware] [TenantResolution] [HttpGet("stats/summary")] public async Task>> GetStatSummary([FromQuery] DateTime? from, [FromQuery] DateTime? to) => Response(await _clientStatService.GetClientStatSummary(new { }, HttpContext, from, to)); [CustomMiddleware] [TenantResolution] [HttpGet("stats/monthly-revenue")] public async Task>> GetMonthlyRevenue([FromQuery] DateTime? from, [FromQuery] DateTime? to) => Response(await _clientStatService.GetMonthlyRevenueVariation(new { }, HttpContext, from, to)); [CustomMiddleware] [TenantResolution] [HttpGet("stats/recent-payments")] public async Task>> GetRecentPayments() => Response(await _clientStatService.GetLastFivePayments(new { }, HttpContext)); [CustomMiddleware] [TenantResolution] [HttpGet("stats/recent-attendance")] public async Task>> GetRecentAttendance() => Response(await _clientStatService.GetLastFiveAttendances(new { }, HttpContext)); // ---------- I. Attendance ---------- // The service resolves the clientId from the JWT in HttpContext. [CustomMiddleware] [TenantResolution] [RequiresPermission("attendance")] [HttpGet("attendance")] public async Task>> GetAllAttendance() => Response(await _attendanceService.GetAllAttendanceRecords(new { }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("attendance")] [HttpGet("attendance/today")] public async Task>> GetTodayAttendance() => Response(await _attendanceService.GetTodayAttendance(new { }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("attendance")] [HttpGet("attendance/counts")] public async Task>> GetAttendanceCounts( [FromQuery] string? fromDate, [FromQuery] string? toDate) => Response(await _attendanceService.GetAttendanceCounts(new { fromDate, toDate }, HttpContext)); // ---------- J. Membership plans (CustomerPlan) ---------- [CustomMiddleware] [TenantResolution] [RequiresPermission("memberships")] [HttpGet("plans")] public async Task>> GetMembershipPlans([FromQuery] bool includeInactive = false) => Response(await _customerPlanService.GetAllCustomerPlans(new { includeInactive }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("memberships")] [HttpGet("plans/{id:guid}")] public async Task>> GetMembershipPlan(Guid id) => Response(await _customerPlanService.GetCustomerPlanById(new { customerPlanId = id }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("memberships")] [HttpPost("plans")] public async Task>> CreateMembershipPlan([FromBody] CreateCustomerPlanRequest request) { // Omit-if-null: the service reads keys with GetString()/GetDecimal(), // which throw on explicit JSON nulls. var payload = new Dictionary { ["planName"] = request.PlanName, ["planPrice"] = request.PlanPrice, }; if (request.PlanDescription != null) payload["planDescription"] = request.PlanDescription; if (request.DurationInDays.HasValue) payload["durationInDays"] = request.DurationInDays.Value; if (request.PlanScope != null) payload["planScope"] = request.PlanScope; if (request.MemberLimit.HasValue) payload["memberLimit"] = request.MemberLimit.Value; if (request.DurationMode != null) payload["durationMode"] = request.DurationMode; if (request.ValidFrom.HasValue) payload["validFrom"] = request.ValidFrom.Value.ToString("o"); if (request.ValidTo.HasValue) payload["validTo"] = request.ValidTo.Value.ToString("o"); if (request.AllowSplitPayments.HasValue) payload["allowSplitPayments"] = request.AllowSplitPayments.Value; if (request.InstallmentCount.HasValue) payload["installmentCount"] = request.InstallmentCount.Value; if (request.HasOnboardingFee.HasValue) payload["hasOnboardingFee"] = request.HasOnboardingFee.Value; if (request.OnboardingFeeAmount.HasValue) payload["onboardingFeeAmount"] = request.OnboardingFeeAmount.Value; if (request.SessionsPerCycle.HasValue) payload["sessionsPerCycle"] = request.SessionsPerCycle.Value; if (request.SessionRolloverPolicy != null) payload["sessionRolloverPolicy"] = request.SessionRolloverPolicy; if (request.RolloverGraceDays.HasValue) payload["rolloverGraceDays"] = request.RolloverGraceDays.Value; if (request.BillingTiming != null) payload["billingTiming"] = request.BillingTiming; if (request.PlaceIds != null) payload["placeIds"] = request.PlaceIds; if (request.FacilityClassId.HasValue) payload["facilityClassId"] = request.FacilityClassId.Value; if (request.GrantsGymAccess.HasValue) payload["grantsGymAccess"] = request.GrantsGymAccess.Value; return Response(await _customerPlanService.CreateCustomerPlan(payload, HttpContext)); } [CustomMiddleware] [TenantResolution] [RequiresPermission("memberships")] [HttpPut("plans/{id:guid}")] public async Task>> UpdateMembershipPlan(Guid id, [FromBody] UpdateCustomerPlanRequest request) { var payload = new Dictionary { ["customerPlanId"] = id }; if (request.PlanName != null) payload["planName"] = request.PlanName; if (request.PlanDescription != null) payload["planDescription"] = request.PlanDescription; if (request.PlanPrice.HasValue) payload["planPrice"] = request.PlanPrice.Value; if (request.DurationInDays.HasValue) payload["durationInDays"] = request.DurationInDays.Value; if (request.PlanScope != null) payload["planScope"] = request.PlanScope; if (request.MemberLimit.HasValue) payload["memberLimit"] = request.MemberLimit.Value; if (request.DurationMode != null) payload["durationMode"] = request.DurationMode; if (request.ValidFrom.HasValue) payload["validFrom"] = request.ValidFrom.Value.ToString("o"); if (request.ValidTo.HasValue) payload["validTo"] = request.ValidTo.Value.ToString("o"); if (request.AllowSplitPayments.HasValue) payload["allowSplitPayments"] = request.AllowSplitPayments.Value; if (request.InstallmentCount.HasValue) payload["installmentCount"] = request.InstallmentCount.Value; if (request.HasOnboardingFee.HasValue) payload["hasOnboardingFee"] = request.HasOnboardingFee.Value; if (request.OnboardingFeeAmount.HasValue) payload["onboardingFeeAmount"] = request.OnboardingFeeAmount.Value; if (request.SessionsPerCycle.HasValue) payload["sessionsPerCycle"] = request.SessionsPerCycle.Value; if (request.SessionRolloverPolicy != null) payload["sessionRolloverPolicy"] = request.SessionRolloverPolicy; if (request.RolloverGraceDays.HasValue) payload["rolloverGraceDays"] = request.RolloverGraceDays.Value; if (request.BillingTiming != null) payload["billingTiming"] = request.BillingTiming; if (request.IsActive.HasValue) payload["isActive"] = request.IsActive.Value; if (request.PlaceIds != null) payload["placeIds"] = request.PlaceIds; return Response(await _customerPlanService.UpdateCustomerPlan(payload, HttpContext)); } // Status-only toggle -- the counterpart to the soft delete below, and the only way // to bring a deactivated plan back. Kept off the PUT so reactivating never has to // resend (or re-satisfy validation on) the plan's whole configuration. [CustomMiddleware] [TenantResolution] [RequiresPermission("memberships")] [HttpPatch("plans/{id:guid}/status")] public async Task>> SetMembershipPlanStatus( Guid id, [FromBody] SetCustomerPlanStatusRequest request) => Response(await _customerPlanService.SetCustomerPlanStatus(new { customerPlanId = id, isActive = request.IsActive }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("memberships")] [HttpDelete("plans/{id:guid}")] public async Task>> DeleteMembershipPlan(Guid id, [FromQuery] bool hard = false) => Response(await _customerPlanService.DeleteCustomerPlan(new { customerPlanId = id, hardDelete = hard }, HttpContext)); // ---------- K. Workout plans ---------- [CustomMiddleware] [TenantResolution] [RequiresPermission("workout-plans")] [HttpGet("workout-plans")] public async Task>> GetWorkoutPlans() => Response(await _workoutService.GetWorkoutPlanList(new { }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("workout-plans")] [HttpGet("workout-plans/{id:guid}")] public async Task>> GetWorkoutPlan(Guid id) => Response(await _workoutService.GetWorkoutPlanById(new { workoutPlanId = id }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("workout-plans")] [HttpPost("workout-plans")] public async Task>> CreateWorkoutPlan([FromBody] WorkoutPlanRequest request) => Response(await _workoutService.CreateWorkoutPlan(BuildWorkoutPlanPayload(request), HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("workout-plans")] [HttpPut("workout-plans/{id:guid}")] public async Task>> UpdateWorkoutPlan(Guid id, [FromBody] WorkoutPlanRequest request) => Response(await _workoutService.UpdateWorkoutPlan(BuildWorkoutPlanPayload(request, id), HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("workout-plans")] [HttpDelete("workout-plans/{id:guid}")] public async Task>> DeleteWorkoutPlan(Guid id) => Response(await _workoutService.DeleteWorkoutPlan(new { workoutPlanId = id }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("workout-plans")] [HttpGet("workout-plans/{id:guid}/items")] public async Task>> GetWorkoutItems(Guid id) => Response(await _workoutService.GetWorkoutItemsByWorkoutPlanId(new { workoutPlanId = id }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("workout-plans")] [HttpPost("workout-plans/{id:guid}/items")] public async Task>> CreateWorkoutItem(Guid id, [FromBody] WorkoutItemRequest request) { var payload = new Dictionary { ["workoutPlanId"] = id, ["exerciseDay"] = request.ExerciseDay, }; if (request.Description != null) payload["description"] = request.Description; if (request.Exercises.HasValue) payload["exercises"] = request.Exercises.Value; if (request.HowToDo != null) payload["howToDo"] = request.HowToDo; if (request.DoURL != null) payload["doURL"] = request.DoURL; return Response(await _workoutService.CreateWorkoutItem(payload, HttpContext)); } [CustomMiddleware] [TenantResolution] [RequiresPermission("workout-plans")] [HttpGet("workout-plans/categories")] public async Task>> GetWorkoutCategories() => Response(await _workoutService.GetWorkoutCategoryList(new { }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("workout-plans")] [HttpPost("workout-plans/categories")] public async Task>> CreateWorkoutCategory([FromBody] PlanCategoryRequest request) => Response(await _workoutService.CreateWorkoutCategory(new { name = request.Name, description = request.Description }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("workout-plans")] [HttpPost("workout-plans/{id:guid}/assignments")] public async Task>> AssignWorkout(Guid id, [FromBody] PlanAssignRequest request) => Response(await _workoutService.AssignWorkoutToCustomer(new { workoutPlanId = id, customerId = request.CustomerId, notes = request.Notes }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("workout-plans")] [HttpDelete("workout-plans/assignments/{customerWorkoutId:guid}")] public async Task>> UnassignWorkout(Guid customerWorkoutId) => Response(await _workoutService.UnassignWorkoutFromCustomer(new { customerWorkoutId }, HttpContext)); // ---------- L. Meal plans ---------- [CustomMiddleware] [TenantResolution] [RequiresPermission("meal-plans")] [HttpGet("meal-plans")] public async Task>> GetMealPlans() => Response(await _mealService.GetMealPlanList(new { }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("meal-plans")] [HttpGet("meal-plans/{id:guid}")] public async Task>> GetMealPlan(Guid id) => Response(await _mealService.GetMealPlanById(new { mealPlanId = id }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("meal-plans")] [HttpPost("meal-plans")] public async Task>> CreateMealPlan([FromBody] MealPlanRequest request) => Response(await _mealService.CreateMealPlan(BuildMealPlanPayload(request), HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("meal-plans")] [HttpPut("meal-plans/{id:guid}")] public async Task>> UpdateMealPlan(Guid id, [FromBody] MealPlanRequest request) => Response(await _mealService.UpdateMealPlan(BuildMealPlanPayload(request, id), HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("meal-plans")] [HttpDelete("meal-plans/{id:guid}")] public async Task>> DeleteMealPlan(Guid id) => Response(await _mealService.DeleteMealPlan(new { mealPlanId = id }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("meal-plans")] [HttpGet("meal-plans/{id:guid}/items")] public async Task>> GetMealItems(Guid id) => Response(await _mealService.GetMealItemsByMealPlanId(new { mealPlanId = id }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("meal-plans")] [HttpPost("meal-plans/{id:guid}/items")] public async Task>> CreateMealItem(Guid id, [FromBody] MealItemRequest request) { var payload = new Dictionary { ["mealPlanId"] = id, ["name"] = request.Name, }; if (request.Description != null) payload["description"] = request.Description; if (request.Calories != null) payload["calories"] = request.Calories; if (request.ServingSize != null) payload["servingSize"] = request.ServingSize; if (request.PreparationNotes != null) payload["preparationNotes"] = request.PreparationNotes; return Response(await _mealService.CreateMealItem(payload, HttpContext)); } [CustomMiddleware] [TenantResolution] [RequiresPermission("meal-plans")] [HttpGet("meal-plans/categories")] public async Task>> GetMealCategories() => Response(await _mealService.GetMealCategoryList(new { }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("meal-plans")] [HttpPost("meal-plans/categories")] public async Task>> CreateMealCategory([FromBody] PlanCategoryRequest request) => Response(await _mealService.CreateMealCategory(new { name = request.Name, description = request.Description }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("meal-plans")] [HttpPost("meal-plans/{id:guid}/assignments")] public async Task>> AssignMeal(Guid id, [FromBody] PlanAssignRequest request) => Response(await _mealService.AssignMealToCustomer(new { mealPlanId = id, customerId = request.CustomerId, notes = request.Notes }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("meal-plans")] [HttpDelete("meal-plans/assignments/{customerMealId:guid}")] public async Task>> UnassignMeal(Guid customerMealId) => Response(await _mealService.UnassignMealFromCustomer(new { customerMealId }, HttpContext)); // ---------- M. Feed ---------- [CustomMiddleware] [TenantResolution] [RequiresPermission("feed")] [HttpGet("feed")] public async Task>> GetFeed() => Response(await _feedService.ShowFeedByClient(new { }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("feed")] [HttpPost("feed")] [RequestSizeLimit(20_000_000)] public async Task>> CreateFeed([FromBody] CreateFeedRequest request) { var payload = new Dictionary { ["caption"] = request.Caption, ["description"] = request.Description, }; if (request.Document.HasValue && request.Document.Value.ValueKind == JsonValueKind.Array) payload["document"] = request.Document.Value; return Response(await _feedService.CreateFeed(payload, HttpContext)); } [CustomMiddleware] [TenantResolution] [RequiresPermission("feed")] [HttpDelete("feed/{postId:guid}")] public async Task>> DeleteFeed(Guid postId) => Response(await _feedService.DeleteFeedByPostId(new { postId }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("feed")] [HttpPost("feed/{postId:guid}/like")] public async Task>> LikeFeed(Guid postId) => Response(await _feedService.LikeAsync(new { postId }, HttpContext)); // FeedService.UnlikeAsync reads userId from the payload (unlike LikeAsync), // so it's injected here from the token — never taken from the caller. [CustomMiddleware] [TenantResolution] [RequiresPermission("feed")] [HttpDelete("feed/{postId:guid}/like")] public async Task>> UnlikeFeed(Guid postId) => Response(await _feedService.UnlikeAsync(new { postId, userId = DataExtractor.GetUserIdFromContext(HttpContext) }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("feed")] [HttpGet("feed/{postId:guid}/like")] public async Task>> IsFeedLiked(Guid postId) => Response(await _feedService.IsLikedAsync(new { postId }, HttpContext)); // ---------- N. Support tickets ---------- // Lists only the caller's own tickets (GetTicketByUserId, never GetTicketList). [CustomMiddleware] [TenantResolution] [RequiresPermission("tickets")] [HttpGet("tickets")] public async Task>> GetMyTickets() => Response(await _ticketService.GetTicketByUserId(new { }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("tickets")] [HttpGet("tickets/{id:guid}")] public async Task>> GetTicket(Guid id) => Response(await _ticketService.GetTicketById(new { ticketId = id }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("tickets")] [HttpGet("tickets/categories")] public async Task>> GetTicketCategories() => Response(await _ticketService.GetTicketCategoryList(new { }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("tickets")] [HttpPost("tickets")] public async Task>> CreateTicket([FromBody] CreateTicketRequest request) { var payload = new Dictionary { ["mainReason"] = request.MainReason }; if (request.TicketCategoryId.HasValue) payload["ticketCategoryId"] = request.TicketCategoryId.Value; return Response(await _ticketService.CreateTicket(payload, HttpContext)); } [CustomMiddleware] [TenantResolution] [RequiresPermission("tickets")] [HttpPut("tickets/{id:guid}")] public async Task>> UpdateTicket(Guid id, [FromBody] UpdateTicketRequest request) { var payload = new Dictionary { ["ticketId"] = id }; if (request.MainReason != null) payload["mainReason"] = request.MainReason; if (request.TicketCategoryId.HasValue) payload["ticketCategoryId"] = request.TicketCategoryId.Value; return Response(await _ticketService.UpdateTicket(payload, HttpContext)); } [CustomMiddleware] [TenantResolution] [RequiresPermission("tickets")] [HttpPost("tickets/{id:guid}/messages")] public async Task>> SendTicketMessage(Guid id, [FromBody] SendTicketMessageRequest request) { var payload = new Dictionary { ["ticketId"] = id, ["message"] = request.Message, }; if (request.AttachmentURL != null) payload["attachmentURL"] = request.AttachmentURL; return Response(await _ticketService.SendMessage(payload, HttpContext)); } [CustomMiddleware] [TenantResolution] [RequiresPermission("tickets")] [HttpPost("tickets/{id:guid}/feedback")] public async Task>> SubmitTicketFeedback(Guid id, [FromBody] TicketFeedbackRequest request) { var payload = new Dictionary { ["ticketId"] = id, ["rating"] = request.Rating, }; if (request.Feedback != null) payload["feedback"] = request.Feedback; return Response(await _ticketService.SubmitTicketFeedback(payload, HttpContext)); } [CustomMiddleware] [TenantResolution] [RequiresPermission("tickets")] [HttpPost("tickets/{id:guid}/close")] public async Task>> CloseTicket(Guid id) => Response(await _ticketService.CloseTicket(new { ticketId = id }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("tickets")] [HttpDelete("tickets/{id:guid}")] public async Task>> DeleteTicket(Guid id) => Response(await _ticketService.DeleteTicket(new { ticketId = id }, HttpContext)); // ---------- O. Subscriptions & group members ---------- [CustomMiddleware] [TenantResolution] [RequiresPermission("members")] [HttpGet("subscriptions/{id:guid}")] public async Task>> GetSubscription(Guid id) => Response(await _customerSubscriptionService.GetSubscriptionSummary(new { subscriptionId = id }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("members")] [HttpPost("subscriptions/{id:guid}/members")] public async Task>> AddSubscriptionMember(Guid id, [FromBody] AddSubscriptionMemberRequest request) => Response(await _customerSubscriptionService.AddMemberToSubscription( new { subscriptionId = id, customerId = request.CustomerId }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("members")] [HttpDelete("subscriptions/{id:guid}/members/{memberId:guid}")] public async Task>> RemoveSubscriptionMember(Guid id, Guid memberId) => Response(await _customerSubscriptionService.RemoveMemberFromSubscription( new { subscriptionId = id, subscriptionMemberId = memberId }, HttpContext)); // Optional extra fees (req 6) — e.g. a coaching fee added once or every renewal. [CustomMiddleware] [TenantResolution] [RequiresPermission("members")] [HttpGet("subscriptions/{id:guid}/extra-fees")] public async Task>>> GetSubscriptionExtraFees(Guid id) => Response(await _customerSubscriptionService.GetExtraFeesAsync(id, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("members")] [HttpPost("subscriptions/{id:guid}/extra-fees")] public async Task>> AddSubscriptionExtraFee(Guid id, [FromBody] AddExtraFeeRequest request) => Response(await _customerSubscriptionService.AddExtraFeeAsync(id, request, HttpContext)); // ---------- Request payload helpers & DTOs ---------- // Services call GetString()/GetBoolean()/GetDecimal() whenever a key exists, // so omitted fields must be left out of the payload entirely rather than sent null. private static Dictionary BuildWorkoutPlanPayload(WorkoutPlanRequest request, Guid? planId = null) { var payload = new Dictionary(); if (planId.HasValue) payload["workoutPlanId"] = planId.Value; if (request.Name != null) payload["name"] = request.Name; if (request.Description != null) payload["description"] = request.Description; if (request.IsTemplate.HasValue) payload["isTemplate"] = request.IsTemplate.Value; if (request.WorkoutCategoryId.HasValue) payload["workoutCategoryId"] = request.WorkoutCategoryId.Value; if (request.CustomerIds != null) payload["customerIds"] = request.CustomerIds; if (request.WorkoutItems.HasValue && request.WorkoutItems.Value.ValueKind == JsonValueKind.Array) payload["workoutItems"] = request.WorkoutItems.Value; return payload; } private static Dictionary BuildMealPlanPayload(MealPlanRequest request, Guid? planId = null) { var payload = new Dictionary(); if (planId.HasValue) payload["mealPlanId"] = planId.Value; if (request.Name != null) payload["name"] = request.Name; if (request.Description != null) payload["description"] = request.Description; if (request.IsTemplate.HasValue) payload["isTemplate"] = request.IsTemplate.Value; if (request.MealCategoryId.HasValue) payload["mealCategoryId"] = request.MealCategoryId.Value; if (request.CustomerIds != null) payload["customerIds"] = request.CustomerIds; if (request.MealItems.HasValue && request.MealItems.Value.ValueKind == JsonValueKind.Array) payload["mealItems"] = request.MealItems.Value; return payload; } public class WorkoutPlanRequest { public string? Name { get; set; } public string? Description { get; set; } public bool? IsTemplate { get; set; } public Guid? WorkoutCategoryId { get; set; } public List? CustomerIds { get; set; } /// Array of { exerciseDay, description?, exercises?, howToDo?, doURL? }. public JsonElement? WorkoutItems { get; set; } } public class WorkoutItemRequest { public string ExerciseDay { get; set; } = string.Empty; public string? Description { get; set; } public JsonElement? Exercises { get; set; } public string? HowToDo { get; set; } public string? DoURL { get; set; } } public class MealItemRequest { public string Name { get; set; } = string.Empty; public string? Description { get; set; } // Sent as strings — the service parses them with decimal.Parse. public string? Calories { get; set; } public string? ServingSize { get; set; } public string? PreparationNotes { get; set; } } public class MealPlanRequest { public string? Name { get; set; } public string? Description { get; set; } public bool? IsTemplate { get; set; } public Guid? MealCategoryId { get; set; } public List? CustomerIds { get; set; } /// Array of meal items ({ name, description?, calories?, mealItems?, … }). public JsonElement? MealItems { get; set; } } public class PlanCategoryRequest { public string Name { get; set; } = string.Empty; public string? Description { get; set; } } public class PlanAssignRequest { public Guid CustomerId { get; set; } public string? Notes { get; set; } } public class CreateFeedRequest { public string? Caption { get; set; } public string? Description { get; set; } /// Array of { FileName, Base64 } consumed by DocumentService. public JsonElement? Document { get; set; } } public class CreateTicketRequest { public string MainReason { get; set; } = string.Empty; public Guid? TicketCategoryId { get; set; } } public class UpdateTicketRequest { public string? MainReason { get; set; } public Guid? TicketCategoryId { get; set; } } public class SendTicketMessageRequest { public string Message { get; set; } = string.Empty; public string? AttachmentURL { get; set; } } public class TicketFeedbackRequest { public int Rating { get; set; } public string? Feedback { get; set; } } public class AddSubscriptionMemberRequest { public Guid CustomerId { get; set; } } public class UpdateLockPolicyRequest { public int? LockAfterDaysOverdue { get; set; } } } }