Files
Core_Be/Controllers/ClientController.cs
HarithaRandunu 5d711bd13e Facility booking-mode redesign, multi-plan subscriptions, and Classes
Implements the facility-classes-booking-dashboard plan (backend):

- Facility.BookingMode (single Scheduled/AdHoc choice) replaced with
  independent SupportsClasses/SupportsBooking flags, so one facility
  can run a class and accept walk-in bookings; a running class now
  blocks walk-in booking for its window on both create and reschedule.
- CustomerPlan.GrantsGymAccess + CustomerPlanClass join table let a
  customer hold one gym-access plan plus any number of class-only
  plans. Replaced the old single-subscription resolution
  (GetActiveSubscriptionByCustomerId/HasAnyActiveSubscriptionAsync)
  with gym-access-aware and class-aware resolvers across booking
  creation/cancellation, plan assignment, and customer self-service.
  Fixed ChangeMembershipPlan/renewal plan-changes, which resolved "the
  subscription to replace" by most-recent-created and would have
  deactivated a member's class subscription instead of their gym one.
- New FacilityClass model: recurring class definitions (Daily/Weekly/
  Monthly/Custom) with lazy occurrence generation (8-week horizon,
  race-safe via a unique index) and proper unlimited-capacity handling.
  "Mark not held" replaces the old bare-cancel: cascades to cancel and
  refund every confirmed booking on the occurrence through the same
  core CancelBooking uses, and frees the slot for walk-in booking.
  Scheduling conflicts (a walk-in booking now overlapping a class) are
  surfaced for staff to resolve via the existing Reschedule flow.
- Renamed the pre-existing "AdHoc" booking-mode terminology to
  "WalkIn" throughout (routes, methods, DTOs, the stored BookingMode
  value) for a clearer, simpler name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-09 14:26:59 +05:30

1225 lines
58 KiB
C#

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<ActionResult<ApiResponse<object>>> GetMyProfile()
=> Response(await _clientService.GetClientById(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpGet("packages")]
public async Task<ActionResult<ApiResponse<object>>> GetPackages()
=> Response(await _clientService.GetPackageList(new { }, HttpContext));
// ---------- C. Own profile & business ----------
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("settings")]
[HttpPut("gym-profile")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> UploadGymLogo([FromForm] IFormFile logo)
=> Response(await _clientService.UploadGymLogo(logo, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("settings")]
[HttpGet("gym-profile/logo")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> GetMyGymTerms()
=> Response(await _clientService.GetMyGymTerms(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("settings")]
[HttpPut("gym-profile/terms")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> SendContactOtp([FromBody] SendContactOtpRequest request)
=> Response(await _clientService.SendContactOtp(new { identifier = request.Identifier }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("settings")]
[HttpPost("me/contact/verify")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> GetCustomers()
=> Response(await _clientService.GetCustomersByClientId(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("members")]
[HttpGet("customers/{customerId:guid}")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> UnassignMembership(Guid customerId)
=> Response(await _clientService.UnassignCustomerMembership(customerId, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("members")]
[HttpGet("access-devices")]
public async Task<ActionResult<ApiResponse<AdminClientDevicesResult>>> 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<ActionResult<ApiResponse<List<UnlinkedFingerprintDto>>>> 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<ActionResult<ApiResponse<object>>> 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<object>(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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> GetLockPolicy()
=> Response(await _clientService.GetLockPolicy(HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("members")]
[HttpPut("settings/lock-policy")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<ProfitLossDto>>> GetProfitLoss([FromQuery] DateTime from, [FromQuery] DateTime to)
=> Response(await _profitLossService.GetProfitLossAsync(from, to, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("reports")]
[HttpGet("ledger-entries")]
public async Task<ActionResult<ApiResponse<List<LedgerEntryDto>>>> GetLedgerEntries([FromQuery] DateTime from, [FromQuery] DateTime to)
=> Response(await _profitLossService.GetManualEntriesAsync(from, to, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("reports")]
[HttpPost("ledger-entries")]
public async Task<ActionResult<ApiResponse<LedgerEntryDto>>> AddLedgerEntry([FromBody] AddLedgerEntryRequest request)
=> Response(await _profitLossService.AddManualEntryAsync(request, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("reports")]
[HttpDelete("ledger-entries/{id:guid}")]
public async Task<ActionResult<ApiResponse<object>>> DeleteLedgerEntry(Guid id)
{
await _profitLossService.DeleteManualEntryAsync(id, HttpContext);
return Response<object>(new { deleted = true });
}
// ---------- E. Staff management ----------
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("staff")]
[HttpGet("staff")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> GetStaffMember(Guid staffId)
=> Response(await _staffService.GetStaffById(new { staffId }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("staff")]
[HttpPost("staff")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<object>(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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> GetStaffPayRate(Guid staffId)
=> Response(await _staffService.GetPayRate(staffId, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("staff")]
[HttpPut("staff/{staffId:guid}/pay-rate")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> GetStaffPayments(Guid staffId)
=> Response(await _staffService.GetPayments(staffId, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("staff")]
[HttpPost("staff/{staffId:guid}/payments")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> GetStatSummary([FromQuery] DateTime? from, [FromQuery] DateTime? to)
=> Response(await _clientStatService.GetClientStatSummary(new { }, HttpContext, from, to));
[CustomMiddleware]
[TenantResolution]
[HttpGet("stats/monthly-revenue")]
public async Task<ActionResult<ApiResponse<object>>> GetMonthlyRevenue([FromQuery] DateTime? from, [FromQuery] DateTime? to)
=> Response(await _clientStatService.GetMonthlyRevenueVariation(new { }, HttpContext, from, to));
[CustomMiddleware]
[TenantResolution]
[HttpGet("stats/recent-payments")]
public async Task<ActionResult<ApiResponse<object>>> GetRecentPayments()
=> Response(await _clientStatService.GetLastFivePayments(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[HttpGet("stats/recent-attendance")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> GetAllAttendance()
=> Response(await _attendanceService.GetAllAttendanceRecords(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("attendance")]
[HttpGet("attendance/today")]
public async Task<ActionResult<ApiResponse<object>>> GetTodayAttendance()
=> Response(await _attendanceService.GetTodayAttendance(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("attendance")]
[HttpGet("attendance/counts")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> GetMembershipPlans([FromQuery] bool includeInactive = false)
=> Response(await _customerPlanService.GetAllCustomerPlans(new { includeInactive }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("memberships")]
[HttpGet("plans/{id:guid}")]
public async Task<ActionResult<ApiResponse<object>>> GetMembershipPlan(Guid id)
=> Response(await _customerPlanService.GetCustomerPlanById(new { customerPlanId = id }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("memberships")]
[HttpPost("plans")]
public async Task<ActionResult<ApiResponse<object>>> CreateMembershipPlan([FromBody] CreateCustomerPlanRequest request)
{
// Omit-if-null: the service reads keys with GetString()/GetDecimal(),
// which throw on explicit JSON nulls.
var payload = new Dictionary<string, object>
{
["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<ActionResult<ApiResponse<object>>> UpdateMembershipPlan(Guid id, [FromBody] UpdateCustomerPlanRequest request)
{
var payload = new Dictionary<string, object> { ["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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> GetWorkoutPlans()
=> Response(await _workoutService.GetWorkoutPlanList(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("workout-plans")]
[HttpGet("workout-plans/{id:guid}")]
public async Task<ActionResult<ApiResponse<object>>> GetWorkoutPlan(Guid id)
=> Response(await _workoutService.GetWorkoutPlanById(new { workoutPlanId = id }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("workout-plans")]
[HttpPost("workout-plans")]
public async Task<ActionResult<ApiResponse<object>>> CreateWorkoutPlan([FromBody] WorkoutPlanRequest request)
=> Response(await _workoutService.CreateWorkoutPlan(BuildWorkoutPlanPayload(request), HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("workout-plans")]
[HttpPut("workout-plans/{id:guid}")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> CreateWorkoutItem(Guid id, [FromBody] WorkoutItemRequest request)
{
var payload = new Dictionary<string, object>
{
["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<ActionResult<ApiResponse<object>>> GetWorkoutCategories()
=> Response(await _workoutService.GetWorkoutCategoryList(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("workout-plans")]
[HttpPost("workout-plans/categories")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> UnassignWorkout(Guid customerWorkoutId)
=> Response(await _workoutService.UnassignWorkoutFromCustomer(new { customerWorkoutId }, HttpContext));
// ---------- L. Meal plans ----------
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("meal-plans")]
[HttpGet("meal-plans")]
public async Task<ActionResult<ApiResponse<object>>> GetMealPlans()
=> Response(await _mealService.GetMealPlanList(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("meal-plans")]
[HttpGet("meal-plans/{id:guid}")]
public async Task<ActionResult<ApiResponse<object>>> GetMealPlan(Guid id)
=> Response(await _mealService.GetMealPlanById(new { mealPlanId = id }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("meal-plans")]
[HttpPost("meal-plans")]
public async Task<ActionResult<ApiResponse<object>>> CreateMealPlan([FromBody] MealPlanRequest request)
=> Response(await _mealService.CreateMealPlan(BuildMealPlanPayload(request), HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("meal-plans")]
[HttpPut("meal-plans/{id:guid}")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> CreateMealItem(Guid id, [FromBody] MealItemRequest request)
{
var payload = new Dictionary<string, object>
{
["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<ActionResult<ApiResponse<object>>> GetMealCategories()
=> Response(await _mealService.GetMealCategoryList(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("meal-plans")]
[HttpPost("meal-plans/categories")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> UnassignMeal(Guid customerMealId)
=> Response(await _mealService.UnassignMealFromCustomer(new { customerMealId }, HttpContext));
// ---------- M. Feed ----------
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("feed")]
[HttpGet("feed")]
public async Task<ActionResult<ApiResponse<object>>> GetFeed()
=> Response(await _feedService.ShowFeedByClient(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("feed")]
[HttpPost("feed")]
[RequestSizeLimit(20_000_000)]
public async Task<ActionResult<ApiResponse<object>>> CreateFeed([FromBody] CreateFeedRequest request)
{
var payload = new Dictionary<string, object?>
{
["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<ActionResult<ApiResponse<object>>> DeleteFeed(Guid postId)
=> Response(await _feedService.DeleteFeedByPostId(new { postId }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("feed")]
[HttpPost("feed/{postId:guid}/like")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> GetMyTickets()
=> Response(await _ticketService.GetTicketByUserId(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("tickets")]
[HttpGet("tickets/{id:guid}")]
public async Task<ActionResult<ApiResponse<object>>> GetTicket(Guid id)
=> Response(await _ticketService.GetTicketById(new { ticketId = id }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("tickets")]
[HttpGet("tickets/categories")]
public async Task<ActionResult<ApiResponse<object>>> GetTicketCategories()
=> Response(await _ticketService.GetTicketCategoryList(new { }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("tickets")]
[HttpPost("tickets")]
public async Task<ActionResult<ApiResponse<object>>> CreateTicket([FromBody] CreateTicketRequest request)
{
var payload = new Dictionary<string, object> { ["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<ActionResult<ApiResponse<object>>> UpdateTicket(Guid id, [FromBody] UpdateTicketRequest request)
{
var payload = new Dictionary<string, object> { ["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<ActionResult<ApiResponse<object>>> SendTicketMessage(Guid id, [FromBody] SendTicketMessageRequest request)
{
var payload = new Dictionary<string, object>
{
["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<ActionResult<ApiResponse<object>>> SubmitTicketFeedback(Guid id, [FromBody] TicketFeedbackRequest request)
{
var payload = new Dictionary<string, object>
{
["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<ActionResult<ApiResponse<object>>> CloseTicket(Guid id)
=> Response(await _ticketService.CloseTicket(new { ticketId = id }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("tickets")]
[HttpDelete("tickets/{id:guid}")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> GetSubscription(Guid id)
=> Response(await _customerSubscriptionService.GetSubscriptionSummary(new { subscriptionId = id }, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("members")]
[HttpPost("subscriptions/{id:guid}/members")]
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<List<CustomerSubItemDto>>>> GetSubscriptionExtraFees(Guid id)
=> Response(await _customerSubscriptionService.GetExtraFeesAsync(id, HttpContext));
[CustomMiddleware]
[TenantResolution]
[RequiresPermission("members")]
[HttpPost("subscriptions/{id:guid}/extra-fees")]
public async Task<ActionResult<ApiResponse<object>>> 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<string, object> BuildWorkoutPlanPayload(WorkoutPlanRequest request, Guid? planId = null)
{
var payload = new Dictionary<string, object>();
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<string, object> BuildMealPlanPayload(MealPlanRequest request, Guid? planId = null)
{
var payload = new Dictionary<string, object>();
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<Guid>? CustomerIds { get; set; }
/// <summary>Array of { exerciseDay, description?, exercises?, howToDo?, doURL? }.</summary>
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<Guid>? CustomerIds { get; set; }
/// <summary>Array of meal items ({ name, description?, calories?, mealItems?, … }).</summary>
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; }
/// <summary>Array of { FileName, Base64 } consumed by DocumentService.</summary>
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; }
}
}
}