5d711bd13e
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>
1225 lines
58 KiB
C#
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; }
|
|
}
|
|
}
|
|
}
|