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>
This commit is contained in:
@@ -34,9 +34,9 @@ namespace Fitness_core_BE.Controllers
|
|||||||
|
|
||||||
[CustomMiddleware]
|
[CustomMiddleware]
|
||||||
[TenantResolution]
|
[TenantResolution]
|
||||||
[HttpPost("api/app/bookings/adhoc")]
|
[HttpPost("api/app/bookings/walkin")]
|
||||||
public async Task<ActionResult<ApiResponse<object>>> CreateMemberAdhocBooking([FromBody] CreateAdhocBookingRequest request)
|
public async Task<ActionResult<ApiResponse<object>>> CreateMemberWalkInBooking([FromBody] CreateWalkInBookingRequest request)
|
||||||
=> Response(await _bookingService.CreateMemberAdhocBooking(new
|
=> Response(await _bookingService.CreateMemberWalkInBooking(new
|
||||||
{
|
{
|
||||||
facilityId = request.FacilityId,
|
facilityId = request.FacilityId,
|
||||||
startTime = request.StartTime.ToString("o"),
|
startTime = request.StartTime.ToString("o"),
|
||||||
@@ -70,9 +70,9 @@ namespace Fitness_core_BE.Controllers
|
|||||||
}, HttpContext));
|
}, HttpContext));
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost("api/public/bookings/guest/adhoc")]
|
[HttpPost("api/public/bookings/guest/walkin")]
|
||||||
public async Task<ActionResult<ApiResponse<object>>> CreateGuestAdhocBooking([FromBody] CreateGuestAdhocBookingRequest request)
|
public async Task<ActionResult<ApiResponse<object>>> CreateGuestWalkInBooking([FromBody] CreateGuestWalkInBookingRequest request)
|
||||||
=> Response(await _bookingService.CreateGuestAdhocBooking(new
|
=> Response(await _bookingService.CreateGuestWalkInBooking(new
|
||||||
{
|
{
|
||||||
facilityId = request.FacilityId,
|
facilityId = request.FacilityId,
|
||||||
startTime = request.StartTime.ToString("o"),
|
startTime = request.StartTime.ToString("o"),
|
||||||
|
|||||||
@@ -639,6 +639,8 @@ namespace Fitness_core_BE.Controllers
|
|||||||
if (request.RolloverGraceDays.HasValue) payload["rolloverGraceDays"] = request.RolloverGraceDays.Value;
|
if (request.RolloverGraceDays.HasValue) payload["rolloverGraceDays"] = request.RolloverGraceDays.Value;
|
||||||
if (request.BillingTiming != null) payload["billingTiming"] = request.BillingTiming;
|
if (request.BillingTiming != null) payload["billingTiming"] = request.BillingTiming;
|
||||||
if (request.PlaceIds != null) payload["placeIds"] = request.PlaceIds;
|
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));
|
return Response(await _customerPlanService.CreateCustomerPlan(payload, HttpContext));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using Fitness_core_BE.DTOs.Facility;
|
||||||
|
using Fitness_core_BE.Helpers;
|
||||||
|
using Fitness_core_BE.Interfaces.Services;
|
||||||
|
using Fitness_core_BE.Models.IOs;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using static Fitness_core_BE.Helpers.JwtMiddleware;
|
||||||
|
using static Fitness_core_BE.Helpers.ApiResponseHelper;
|
||||||
|
|
||||||
|
namespace Fitness_core_BE.Controllers
|
||||||
|
{
|
||||||
|
// Recurring class definitions + their occurrences -- the Classes tab's entry point. Gated on
|
||||||
|
// "memberships" (same as FacilityController's own mutations) since class setup is staff/
|
||||||
|
// membership configuration, not a booking-time read. The existing GET api/client/facilities/
|
||||||
|
// sessions* ("bookings"-gated) stays separate for the Booking Dashboard calendar.
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/client/facility-classes")]
|
||||||
|
public class FacilityClassController(IClassService classService, IFacilityService facilityService) : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IClassService _classService = classService;
|
||||||
|
private readonly IFacilityService _facilityService = facilityService;
|
||||||
|
|
||||||
|
[CustomMiddleware]
|
||||||
|
[TenantResolution]
|
||||||
|
[RequiresPermission("memberships")]
|
||||||
|
[HttpGet("")]
|
||||||
|
public async Task<ActionResult<ApiResponse<object>>> GetFacilityClasses([FromQuery] bool includeInactive = false)
|
||||||
|
=> Response(await _classService.GetFacilityClasses(new { includeInactive }, HttpContext));
|
||||||
|
|
||||||
|
[CustomMiddleware]
|
||||||
|
[TenantResolution]
|
||||||
|
[RequiresPermission("memberships")]
|
||||||
|
[HttpPost("")]
|
||||||
|
public async Task<ActionResult<ApiResponse<object>>> CreateFacilityClass([FromBody] CreateFacilityClassRequest request)
|
||||||
|
=> Response(await _classService.CreateFacilityClass(new
|
||||||
|
{
|
||||||
|
facilityId = request.FacilityId,
|
||||||
|
name = request.Name,
|
||||||
|
description = request.Description,
|
||||||
|
recurrenceType = request.RecurrenceType,
|
||||||
|
startTimeOfDay = request.StartTimeOfDay,
|
||||||
|
durationMinutes = request.DurationMinutes,
|
||||||
|
weekDaysMask = request.WeekDaysMask,
|
||||||
|
monthDays = request.MonthDays,
|
||||||
|
seriesStartDate = request.SeriesStartDate?.ToString("o"),
|
||||||
|
seriesEndDate = request.SeriesEndDate?.ToString("o"),
|
||||||
|
capacity = request.Capacity,
|
||||||
|
isUnlimitedCapacity = request.IsUnlimitedCapacity,
|
||||||
|
customOccurrences = request.CustomOccurrences?.Select(o => new { startTime = o.StartTime.ToString("o"), durationMinutes = o.DurationMinutes })
|
||||||
|
}, HttpContext));
|
||||||
|
|
||||||
|
[CustomMiddleware]
|
||||||
|
[TenantResolution]
|
||||||
|
[RequiresPermission("memberships")]
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
public async Task<ActionResult<ApiResponse<object>>> UpdateFacilityClass(Guid id, [FromBody] UpdateFacilityClassRequest request)
|
||||||
|
{
|
||||||
|
var payload = new Dictionary<string, object?> { ["facilityClassId"] = id };
|
||||||
|
if (request.Name != null) payload["name"] = request.Name;
|
||||||
|
if (request.Description != null) payload["description"] = request.Description;
|
||||||
|
if (request.RecurrenceType != null) payload["recurrenceType"] = request.RecurrenceType;
|
||||||
|
if (request.StartTimeOfDay != null) payload["startTimeOfDay"] = request.StartTimeOfDay;
|
||||||
|
if (request.DurationMinutes.HasValue) payload["durationMinutes"] = request.DurationMinutes.Value;
|
||||||
|
if (request.WeekDaysMask.HasValue) payload["weekDaysMask"] = request.WeekDaysMask.Value;
|
||||||
|
if (request.MonthDays != null) payload["monthDays"] = request.MonthDays;
|
||||||
|
if (request.SeriesStartDate.HasValue) payload["seriesStartDate"] = request.SeriesStartDate.Value.ToString("o");
|
||||||
|
if (request.SeriesEndDate.HasValue) payload["seriesEndDate"] = request.SeriesEndDate.Value.ToString("o");
|
||||||
|
if (request.Capacity.HasValue) payload["capacity"] = request.Capacity.Value;
|
||||||
|
if (request.IsUnlimitedCapacity.HasValue) payload["isUnlimitedCapacity"] = request.IsUnlimitedCapacity.Value;
|
||||||
|
if (request.IsActive.HasValue) payload["isActive"] = request.IsActive.Value;
|
||||||
|
if (request.AddCustomOccurrences != null)
|
||||||
|
payload["addCustomOccurrences"] = request.AddCustomOccurrences.Select(o => new { startTime = o.StartTime.ToString("o"), durationMinutes = o.DurationMinutes });
|
||||||
|
|
||||||
|
return Response(await _classService.UpdateFacilityClass(payload, HttpContext));
|
||||||
|
}
|
||||||
|
|
||||||
|
[CustomMiddleware]
|
||||||
|
[TenantResolution]
|
||||||
|
[RequiresPermission("memberships")]
|
||||||
|
[HttpGet("{id:guid}/occurrences")]
|
||||||
|
public async Task<ActionResult<ApiResponse<object>>> GetOccurrences(Guid id, [FromQuery] bool includeCancelled = false)
|
||||||
|
=> Response(await _classService.GetOccurrences(new { facilityClassId = id, includeCancelled }, HttpContext));
|
||||||
|
|
||||||
|
[CustomMiddleware]
|
||||||
|
[TenantResolution]
|
||||||
|
[RequiresPermission("memberships")]
|
||||||
|
[HttpPost("{id:guid}/occurrences/{occurrenceId:guid}/not-held")]
|
||||||
|
public async Task<ActionResult<ApiResponse<object>>> MarkOccurrenceNotHeld(Guid id, Guid occurrenceId, [FromBody] MarkNotHeldRequest? request)
|
||||||
|
=> Response(await _facilityService.CancelFacilitySession(new { facilitySessionId = occurrenceId, reason = request?.Reason }, HttpContext));
|
||||||
|
|
||||||
|
[CustomMiddleware]
|
||||||
|
[TenantResolution]
|
||||||
|
[RequiresPermission("memberships")]
|
||||||
|
[HttpGet("scheduling-conflicts")]
|
||||||
|
public async Task<ActionResult<ApiResponse<object>>> GetSchedulingConflicts()
|
||||||
|
=> Response(await _classService.GetSchedulingConflicts(new { }, HttpContext));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,8 @@ namespace Fitness_core_BE.Controllers
|
|||||||
=> Response(await _facilityService.CreateFacility(new
|
=> Response(await _facilityService.CreateFacility(new
|
||||||
{
|
{
|
||||||
name = request.Name,
|
name = request.Name,
|
||||||
bookingMode = request.BookingMode,
|
supportsClasses = request.SupportsClasses,
|
||||||
|
supportsBooking = request.SupportsBooking,
|
||||||
description = request.Description,
|
description = request.Description,
|
||||||
defaultCapacity = request.DefaultCapacity,
|
defaultCapacity = request.DefaultCapacity,
|
||||||
numberOfUnits = request.NumberOfUnits,
|
numberOfUnits = request.NumberOfUnits,
|
||||||
@@ -48,7 +49,8 @@ namespace Fitness_core_BE.Controllers
|
|||||||
{
|
{
|
||||||
var payload = new Dictionary<string, object> { ["facilityId"] = id };
|
var payload = new Dictionary<string, object> { ["facilityId"] = id };
|
||||||
if (request.Name != null) payload["name"] = request.Name;
|
if (request.Name != null) payload["name"] = request.Name;
|
||||||
if (request.BookingMode != null) payload["bookingMode"] = request.BookingMode;
|
if (request.SupportsClasses.HasValue) payload["supportsClasses"] = request.SupportsClasses.Value;
|
||||||
|
if (request.SupportsBooking.HasValue) payload["supportsBooking"] = request.SupportsBooking.Value;
|
||||||
if (request.Description != null) payload["description"] = request.Description;
|
if (request.Description != null) payload["description"] = request.Description;
|
||||||
if (request.DefaultCapacity.HasValue) payload["defaultCapacity"] = request.DefaultCapacity.Value;
|
if (request.DefaultCapacity.HasValue) payload["defaultCapacity"] = request.DefaultCapacity.Value;
|
||||||
if (request.NumberOfUnits.HasValue) payload["numberOfUnits"] = request.NumberOfUnits.Value;
|
if (request.NumberOfUnits.HasValue) payload["numberOfUnits"] = request.NumberOfUnits.Value;
|
||||||
@@ -90,20 +92,21 @@ namespace Fitness_core_BE.Controllers
|
|||||||
[RequiresPermission("bookings")]
|
[RequiresPermission("bookings")]
|
||||||
[HttpGet("sessions")]
|
[HttpGet("sessions")]
|
||||||
public async Task<ActionResult<ApiResponse<object>>> GetSessions(
|
public async Task<ActionResult<ApiResponse<object>>> GetSessions(
|
||||||
[FromQuery] Guid? facilityId, [FromQuery] DateTime? fromDate, [FromQuery] DateTime? toDate)
|
[FromQuery] Guid? facilityId, [FromQuery] DateTime? fromDate, [FromQuery] DateTime? toDate, [FromQuery] bool includeCancelled = false)
|
||||||
=> Response(await _facilityService.GetSessions(new
|
=> Response(await _facilityService.GetSessions(new
|
||||||
{
|
{
|
||||||
facilityId,
|
facilityId,
|
||||||
fromDate = fromDate?.ToString("o"),
|
fromDate = fromDate?.ToString("o"),
|
||||||
toDate = toDate?.ToString("o")
|
toDate = toDate?.ToString("o"),
|
||||||
|
includeCancelled
|
||||||
}, HttpContext));
|
}, HttpContext));
|
||||||
|
|
||||||
[CustomMiddleware]
|
[CustomMiddleware]
|
||||||
[TenantResolution]
|
[TenantResolution]
|
||||||
[RequiresPermission("bookings")]
|
[RequiresPermission("bookings")]
|
||||||
[HttpDelete("sessions/{id:guid}")]
|
[HttpDelete("sessions/{id:guid}")]
|
||||||
public async Task<ActionResult<ApiResponse<object>>> CancelFacilitySession(Guid id)
|
public async Task<ActionResult<ApiResponse<object>>> CancelFacilitySession(Guid id, [FromBody] MarkNotHeldRequest? request)
|
||||||
=> Response(await _facilityService.CancelFacilitySession(new { facilitySessionId = id }, HttpContext));
|
=> Response(await _facilityService.CancelFacilitySession(new { facilitySessionId = id, reason = request?.Reason }, HttpContext));
|
||||||
|
|
||||||
[CustomMiddleware]
|
[CustomMiddleware]
|
||||||
[TenantResolution]
|
[TenantResolution]
|
||||||
|
|||||||
@@ -4,20 +4,20 @@ namespace Fitness_core_BE.DTOs.Booking
|
|||||||
{
|
{
|
||||||
public Guid BookingId { get; set; }
|
public Guid BookingId { get; set; }
|
||||||
|
|
||||||
/// <summary>Null for an AdHoc booking (no pre-created session).</summary>
|
/// <summary>Null for a WalkIn booking (no pre-created session).</summary>
|
||||||
public Guid? FacilitySessionId { get; set; }
|
public Guid? FacilitySessionId { get; set; }
|
||||||
public Guid FacilityId { get; set; }
|
public Guid FacilityId { get; set; }
|
||||||
public string? FacilityName { get; set; }
|
public string? FacilityName { get; set; }
|
||||||
|
|
||||||
/// <summary>"Scheduled" | "AdHoc" -- read off the facility, tells the FE which action set
|
/// <summary>"Scheduled" | "WalkIn" -- tells the FE which action set applies (e.g. WalkIn
|
||||||
/// applies (e.g. AdHoc bookings can be rescheduled to any free time; Scheduled ones only
|
/// bookings can be rescheduled to any free time; Scheduled ones only to another
|
||||||
/// to another session).</summary>
|
/// session).</summary>
|
||||||
public string BookingMode { get; set; } = string.Empty;
|
public string BookingMode { get; set; } = string.Empty;
|
||||||
|
|
||||||
public DateTime StartTime { get; set; }
|
public DateTime StartTime { get; set; }
|
||||||
public DateTime EndTime { get; set; }
|
public DateTime EndTime { get; set; }
|
||||||
|
|
||||||
/// <summary>Informational headcount, AdHoc only.</summary>
|
/// <summary>Informational headcount, WalkIn only.</summary>
|
||||||
public int? PartySize { get; set; }
|
public int? PartySize { get; set; }
|
||||||
|
|
||||||
public bool IsGuest { get; set; }
|
public bool IsGuest { get; set; }
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ namespace Fitness_core_BE.DTOs.Booking
|
|||||||
public Guid? CustomerId { get; set; }
|
public Guid? CustomerId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CreateAdhocBookingRequest
|
public class CreateWalkInBookingRequest
|
||||||
{
|
{
|
||||||
[Required]
|
[Required]
|
||||||
public Guid FacilityId { get; set; }
|
public Guid FacilityId { get; set; }
|
||||||
@@ -39,7 +39,7 @@ namespace Fitness_core_BE.DTOs.Booking
|
|||||||
public int? PartySize { get; set; }
|
public int? PartySize { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CreateGuestAdhocBookingRequest
|
public class CreateGuestWalkInBookingRequest
|
||||||
{
|
{
|
||||||
[Required]
|
[Required]
|
||||||
public Guid FacilityId { get; set; }
|
public Guid FacilityId { get; set; }
|
||||||
@@ -98,7 +98,7 @@ namespace Fitness_core_BE.DTOs.Booking
|
|||||||
public string OtpCode { get; set; } = string.Empty;
|
public string OtpCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>Picked from the club's managed RFID tag pool (GET api/client/rfid-tags?
|
/// <summary>Picked from the club's managed RFID tag pool (GET api/client/rfid-tags?
|
||||||
/// status=Available) -- never free-typed. Required in practice for an AdHoc booking's
|
/// status=Available) -- never free-typed. Required in practice for a WalkIn booking's
|
||||||
/// check-in; optional for Scheduled bookings that don't hand out a physical tag.</summary>
|
/// check-in; optional for Scheduled bookings that don't hand out a physical tag.</summary>
|
||||||
public Guid? RfidTagId { get; set; }
|
public Guid? RfidTagId { get; set; }
|
||||||
}
|
}
|
||||||
@@ -115,14 +115,14 @@ namespace Fitness_core_BE.DTOs.Booking
|
|||||||
public string? GuestPhone { get; set; }
|
public string? GuestPhone { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Exactly one shape applies, matching the booking's current BookingMode: AdHoc
|
/// <summary>Exactly one shape applies, matching the booking's current BookingMode: WalkIn
|
||||||
/// bookings move within the SAME facility to a new StartTime/DurationMinutes; Scheduled
|
/// bookings move within the SAME facility to a new StartTime/DurationMinutes; Scheduled
|
||||||
/// bookings move to a different FacilitySession (also on the same facility). Only allowed
|
/// bookings move to a different FacilitySession (also on the same facility). Only allowed
|
||||||
/// while the booking is still Confirmed, and only onto a slot with room -- re-runs the same
|
/// while the booking is still Confirmed, and only onto a slot with room -- re-runs the same
|
||||||
/// availability check as creation, excluding the booking's own current slot.</summary>
|
/// availability check as creation, excluding the booking's own current slot.</summary>
|
||||||
public class RescheduleBookingRequest
|
public class RescheduleBookingRequest
|
||||||
{
|
{
|
||||||
// AdHoc reschedule.
|
// WalkIn reschedule.
|
||||||
public DateTime? StartTime { get; set; }
|
public DateTime? StartTime { get; set; }
|
||||||
[Range(1, int.MaxValue, ErrorMessage = "DurationMinutes must be at least 1")]
|
[Range(1, int.MaxValue, ErrorMessage = "DurationMinutes must be at least 1")]
|
||||||
public int? DurationMinutes { get; set; }
|
public int? DurationMinutes { get; set; }
|
||||||
|
|||||||
@@ -58,5 +58,14 @@ namespace Fitness_core_BE.DTOs.CustomerPlan
|
|||||||
/// <summary>Places this plan grants access to. Null/omitted or empty = unrestricted
|
/// <summary>Places this plan grants access to. Null/omitted or empty = unrestricted
|
||||||
/// (reaches every active access device, today's behavior).</summary>
|
/// (reaches every active access device, today's behavior).</summary>
|
||||||
public List<Guid>? PlaceIds { get; set; }
|
public List<Guid>? PlaceIds { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Set only by the Classes tab's "Manage Plan" action -- links this new plan to
|
||||||
|
/// one class via CustomerPlanClass. Omitted for an ordinary gym plan.</summary>
|
||||||
|
public Guid? FacilityClassId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Only meaningful when FacilityClassId is set -- whether this class plan also
|
||||||
|
/// grants general gym-floor access ("class + gym" as one product) instead of class-only.
|
||||||
|
/// Defaults to false (class-only) when omitted.</summary>
|
||||||
|
public bool? GrantsGymAccess { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
namespace Fitness_core_BE.DTOs.Facility
|
||||||
|
{
|
||||||
|
public class FacilityClassDto
|
||||||
|
{
|
||||||
|
public Guid FacilityClassId { get; set; }
|
||||||
|
public Guid FacilityId { get; set; }
|
||||||
|
public string? FacilityName { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
/// <summary>"Daily" | "Weekly" | "Monthly" | "Custom".</summary>
|
||||||
|
public string RecurrenceType { get; set; } = string.Empty;
|
||||||
|
/// <summary>"HH:mm" local time -- null for Custom.</summary>
|
||||||
|
public string? StartTimeOfDay { get; set; }
|
||||||
|
public int DurationMinutes { get; set; }
|
||||||
|
public int WeekDaysMask { get; set; }
|
||||||
|
public string? MonthDays { get; set; }
|
||||||
|
public DateTime SeriesStartDate { get; set; }
|
||||||
|
public DateTime? SeriesEndDate { get; set; }
|
||||||
|
public int? Capacity { get; set; }
|
||||||
|
public bool IsUnlimitedCapacity { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A confirmed ad-hoc booking whose window overlaps an active class occurrence on
|
||||||
|
/// the same facility -- surfaced so staff can resolve it via the existing Reschedule flow.</summary>
|
||||||
|
public class SchedulingConflictDto
|
||||||
|
{
|
||||||
|
public Guid BookingId { get; set; }
|
||||||
|
public Guid FacilityId { get; set; }
|
||||||
|
public string? FacilityName { get; set; }
|
||||||
|
public string? GuestName { get; set; }
|
||||||
|
public DateTime StartTime { get; set; }
|
||||||
|
public DateTime EndTime { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Fitness_core_BE.DTOs.Facility
|
||||||
|
{
|
||||||
|
public class CustomOccurrenceRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public DateTime StartTime { get; set; }
|
||||||
|
|
||||||
|
[Required, Range(1, int.MaxValue, ErrorMessage = "DurationMinutes must be at least 1")]
|
||||||
|
public int DurationMinutes { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateFacilityClassRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public Guid FacilityId { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
/// <summary>"Daily" | "Weekly" | "Monthly" | "Custom".</summary>
|
||||||
|
[Required]
|
||||||
|
public string RecurrenceType { get; set; } = "Weekly";
|
||||||
|
|
||||||
|
/// <summary>"HH:mm" local time -- required unless RecurrenceType == "Custom".</summary>
|
||||||
|
public string? StartTimeOfDay { get; set; }
|
||||||
|
|
||||||
|
[Range(1, int.MaxValue, ErrorMessage = "DurationMinutes must be at least 1")]
|
||||||
|
public int DurationMinutes { get; set; } = 60;
|
||||||
|
|
||||||
|
/// <summary>Weekly only. Bit flags, Sunday = 1 through Saturday = 64.</summary>
|
||||||
|
public int WeekDaysMask { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Monthly only. Comma-separated day-of-month list, e.g. "1,15".</summary>
|
||||||
|
public string? MonthDays { get; set; }
|
||||||
|
|
||||||
|
public DateTime? SeriesStartDate { get; set; }
|
||||||
|
public DateTime? SeriesEndDate { get; set; }
|
||||||
|
|
||||||
|
public int? Capacity { get; set; }
|
||||||
|
public bool IsUnlimitedCapacity { get; set; }
|
||||||
|
|
||||||
|
/// <summary>RecurrenceType == "Custom" only -- explicit occurrence windows.</summary>
|
||||||
|
public List<CustomOccurrenceRequest>? CustomOccurrences { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateFacilityClassRequest
|
||||||
|
{
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? RecurrenceType { get; set; }
|
||||||
|
public string? StartTimeOfDay { get; set; }
|
||||||
|
public int? DurationMinutes { get; set; }
|
||||||
|
public int? WeekDaysMask { get; set; }
|
||||||
|
public string? MonthDays { get; set; }
|
||||||
|
public DateTime? SeriesStartDate { get; set; }
|
||||||
|
public DateTime? SeriesEndDate { get; set; }
|
||||||
|
public int? Capacity { get; set; }
|
||||||
|
public bool? IsUnlimitedCapacity { get; set; }
|
||||||
|
public bool? IsActive { get; set; }
|
||||||
|
|
||||||
|
/// <summary>RecurrenceType == "Custom" only -- adds more explicit occurrences without
|
||||||
|
/// touching existing ones (Custom has no rule to regenerate from).</summary>
|
||||||
|
public List<CustomOccurrenceRequest>? AddCustomOccurrences { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MarkNotHeldRequest
|
||||||
|
{
|
||||||
|
public string? Reason { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@ namespace Fitness_core_BE.DTOs.Facility
|
|||||||
public Guid FacilityId { get; set; }
|
public Guid FacilityId { get; set; }
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>"Scheduled" | "AdHoc".</summary>
|
public bool SupportsClasses { get; set; }
|
||||||
public string BookingMode { get; set; } = "Scheduled";
|
public bool SupportsBooking { get; set; }
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
public int DefaultCapacity { get; set; }
|
public int DefaultCapacity { get; set; }
|
||||||
public int NumberOfUnits { get; set; }
|
public int NumberOfUnits { get; set; }
|
||||||
@@ -27,20 +27,35 @@ namespace Fitness_core_BE.DTOs.Facility
|
|||||||
public DateTime StartTime { get; set; }
|
public DateTime StartTime { get; set; }
|
||||||
public DateTime EndTime { get; set; }
|
public DateTime EndTime { get; set; }
|
||||||
public int Capacity { get; set; }
|
public int Capacity { get; set; }
|
||||||
|
/// <summary>When true, Capacity above is meaningless -- this occurrence has no cap.</summary>
|
||||||
|
public bool IsUnlimitedCapacity { get; set; }
|
||||||
public int BookedCount { get; set; }
|
public int BookedCount { get; set; }
|
||||||
public bool IsCancelled { get; set; }
|
public bool IsCancelled { get; set; }
|
||||||
|
/// <summary>Null for a manually-created session; set for a class occurrence.</summary>
|
||||||
|
public Guid? FacilityClassId { get; set; }
|
||||||
|
public string? FacilityClassName { get; set; }
|
||||||
|
public string? NotHeldReason { get; set; }
|
||||||
|
public DateTime? NotHeldAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>GET api/client/facilities/availability -- how much room is left for a requested
|
/// <summary>GET api/client/facilities/availability -- how much room is left for a requested
|
||||||
/// window, uniformly across both booking modes (TotalUnits/UnitsFree mean NumberOfUnits/
|
/// window. A facility can support both booking and classes at once, so the two number pairs
|
||||||
/// overlap-count for AdHoc, DefaultCapacity/free-session-spots for Scheduled).</summary>
|
/// are independently nullable: null means "this facility doesn't offer that mode," not zero.
|
||||||
|
/// BookingUnitsFree is forced to 0 when an active (non-cancelled) class occupies the window --
|
||||||
|
/// a running class occupies the whole facility, not just a seat.</summary>
|
||||||
public class FacilityAvailabilityDto
|
public class FacilityAvailabilityDto
|
||||||
{
|
{
|
||||||
public Guid FacilityId { get; set; }
|
public Guid FacilityId { get; set; }
|
||||||
public string FacilityName { get; set; } = string.Empty;
|
public string FacilityName { get; set; } = string.Empty;
|
||||||
public string BookingMode { get; set; } = string.Empty;
|
public bool SupportsClasses { get; set; }
|
||||||
public int TotalUnits { get; set; }
|
public bool SupportsBooking { get; set; }
|
||||||
public int UnitsFree { get; set; }
|
public int? BookingTotalUnits { get; set; }
|
||||||
|
public int? BookingUnitsFree { get; set; }
|
||||||
|
public int? ClassTotalCapacity { get; set; }
|
||||||
|
public int? ClassUnitsFree { get; set; }
|
||||||
|
/// <summary>True when an overlapping class occurrence has no headcount cap -- when set,
|
||||||
|
/// ClassTotalCapacity/ClassUnitsFree stay null (unbounded, not zero).</summary>
|
||||||
|
public bool ClassIsUnlimitedCapacity { get; set; }
|
||||||
public decimal PricePerHour { get; set; }
|
public decimal PricePerHour { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,17 +7,17 @@ namespace Fitness_core_BE.DTOs.Facility
|
|||||||
[Required]
|
[Required]
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>"Scheduled" | "AdHoc".</summary>
|
/// <summary>At least one of SupportsClasses/SupportsBooking must be true.</summary>
|
||||||
[Required]
|
public bool SupportsClasses { get; set; } = true;
|
||||||
public string BookingMode { get; set; } = "Scheduled";
|
public bool SupportsBooking { get; set; } = false;
|
||||||
|
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
|
|
||||||
/// <summary>Required (meaningfully) when BookingMode == "Scheduled".</summary>
|
/// <summary>Required (meaningfully) when SupportsClasses.</summary>
|
||||||
[Range(1, int.MaxValue, ErrorMessage = "DefaultCapacity must be at least 1")]
|
[Range(1, int.MaxValue, ErrorMessage = "DefaultCapacity must be at least 1")]
|
||||||
public int DefaultCapacity { get; set; } = 1;
|
public int DefaultCapacity { get; set; } = 1;
|
||||||
|
|
||||||
/// <summary>Required (meaningfully) when BookingMode == "AdHoc" -- e.g. 3 for "3 courts".</summary>
|
/// <summary>Required (meaningfully) when SupportsBooking -- e.g. 3 for "3 courts".</summary>
|
||||||
[Range(1, int.MaxValue, ErrorMessage = "NumberOfUnits must be at least 1")]
|
[Range(1, int.MaxValue, ErrorMessage = "NumberOfUnits must be at least 1")]
|
||||||
public int NumberOfUnits { get; set; } = 1;
|
public int NumberOfUnits { get; set; } = 1;
|
||||||
|
|
||||||
@@ -30,7 +30,8 @@ namespace Fitness_core_BE.DTOs.Facility
|
|||||||
[Required]
|
[Required]
|
||||||
public Guid FacilityId { get; set; }
|
public Guid FacilityId { get; set; }
|
||||||
public string? Name { get; set; }
|
public string? Name { get; set; }
|
||||||
public string? BookingMode { get; set; }
|
public bool? SupportsClasses { get; set; }
|
||||||
|
public bool? SupportsBooking { get; set; }
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
public int? DefaultCapacity { get; set; }
|
public int? DefaultCapacity { get; set; }
|
||||||
public int? NumberOfUnits { get; set; }
|
public int? NumberOfUnits { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
namespace Fitness_core_BE.Helpers
|
||||||
|
{
|
||||||
|
// Every gym this runs for is Sri Lanka-based -- shared by SubscriptionRenewalJob (daily
|
||||||
|
// trigger wake-up) and the Classes feature (converting a class's local StartTimeOfDay to
|
||||||
|
// UTC per generated occurrence). Lifted out of SubscriptionRenewalJob rather than each
|
||||||
|
// caller re-deriving it.
|
||||||
|
public static class TimeZoneHelper
|
||||||
|
{
|
||||||
|
public static readonly TimeZoneInfo SriLanka = ResolveSriLankaTimeZone();
|
||||||
|
|
||||||
|
private static TimeZoneInfo ResolveSriLankaTimeZone()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return TimeZoneInfo.FindSystemTimeZoneById("Sri Lanka Standard Time"); // Windows ID
|
||||||
|
}
|
||||||
|
catch (TimeZoneNotFoundException)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return TimeZoneInfo.FindSystemTimeZoneById("Asia/Colombo"); // IANA ID (Linux/macOS)
|
||||||
|
}
|
||||||
|
catch (TimeZoneNotFoundException)
|
||||||
|
{
|
||||||
|
// Neither ID resolved (e.g. no tzdata on the host) -- Sri Lanka has kept a
|
||||||
|
// fixed UTC+5:30 offset with no DST since 2006, so a custom fixed-offset
|
||||||
|
// zone is a safe fallback rather than failing the whole caller.
|
||||||
|
return TimeZoneInfo.CreateCustomTimeZone(
|
||||||
|
"Sri Lanka (fixed +05:30)", TimeSpan.FromMinutes(330), "Sri Lanka Time", "Sri Lanka Time");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ namespace Fitness_core_BE.Interfaces
|
|||||||
Task<List<Booking>> GetBookingsForCustomer(Guid customerId);
|
Task<List<Booking>> GetBookingsForCustomer(Guid customerId);
|
||||||
Task<bool> CustomerHasActiveBookingForSession(Guid facilitySessionId, Guid customerId);
|
Task<bool> CustomerHasActiveBookingForSession(Guid facilitySessionId, Guid customerId);
|
||||||
|
|
||||||
/// <summary>Unified roster query covering both Scheduled and AdHoc bookings -- backs
|
/// <summary>Unified roster query covering both Scheduled and WalkIn bookings -- backs
|
||||||
/// GET api/client/bookings. All filters are optional/AND-combined.</summary>
|
/// GET api/client/bookings. All filters are optional/AND-combined.</summary>
|
||||||
Task<List<Booking>> GetBookings(Guid clientId, Guid? facilityId, Guid? facilitySessionId, DateTime? fromUtc, DateTime? toUtc, string? status);
|
Task<List<Booking>> GetBookings(Guid clientId, Guid? facilityId, Guid? facilitySessionId, DateTime? fromUtc, DateTime? toUtc, string? status);
|
||||||
|
|
||||||
|
|||||||
@@ -23,14 +23,31 @@ namespace Fitness_core_BE.Interfaces
|
|||||||
Task<CustomerCards?> GetCardByIdAsync(Guid cardId, CancellationToken ct = default);
|
Task<CustomerCards?> GetCardByIdAsync(Guid cardId, CancellationToken ct = default);
|
||||||
Task<CustomerSubscription?> GetCustomerSubscriptionById(Guid subscriptionId);
|
Task<CustomerSubscription?> GetCustomerSubscriptionById(Guid subscriptionId);
|
||||||
Task<List<CustomerSubscription>> GetCustomerSubscriptionsByCustomerId(Guid customerId);
|
Task<List<CustomerSubscription>> GetCustomerSubscriptionsByCustomerId(Guid customerId);
|
||||||
Task<CustomerSubscription?> GetActiveSubscriptionByCustomerId(Guid customerId);
|
/// <summary>True for an ordinary plan (no CustomerPlanClass links) or a class-linked
|
||||||
|
/// plan with GrantsGymAccess == true.</summary>
|
||||||
|
Task<bool> PlanGrantsGymAccessAsync(Guid customerPlanId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>The customer's active subscription whose plan grants gym access, if any --
|
||||||
|
/// at most one can exist (enforced by HasActiveGymAccessPlanAsync at assignment time).
|
||||||
|
/// Primary-only, same as the old GetActiveSubscriptionByCustomerId this replaces.</summary>
|
||||||
|
Task<CustomerSubscription?> GetActiveGymAccessSubscriptionAsync(Guid customerId);
|
||||||
|
|
||||||
|
/// <summary>The customer's active subscription on this exact plan, if any -- the
|
||||||
|
/// idempotency check for re-assigning a class-only plan (which, unlike a gym plan,
|
||||||
|
/// doesn't block on ANY other active plan, only a duplicate of itself).</summary>
|
||||||
|
Task<CustomerSubscription?> GetActiveSubscriptionForPlanAsync(Guid customerId, Guid customerPlanId);
|
||||||
|
|
||||||
|
/// <summary>The subscription that covers booking a specific class, preferring one with
|
||||||
|
/// quota left this cycle. Null if none of the customer's active subscriptions link to
|
||||||
|
/// this class at all.</summary>
|
||||||
|
Task<CustomerSubscription?> GetActiveSubscriptionForClassAsync(Guid customerId, Guid facilityClassId);
|
||||||
|
|
||||||
// Whether this customer is currently an active member (primary OR secondary/group) of
|
// Whether this customer is currently an active member (primary OR secondary/group) of
|
||||||
// ANY subscription at this gym. GetActiveSubscriptionByCustomerId only sees subscriptions
|
// ANY gym-access subscription at this gym -- a class-only subscription never counts, so
|
||||||
// where they're the primary -- this also catches a group co-member, so "one customer, one
|
// unlimited concurrent class-only subscriptions are allowed. "One customer, one
|
||||||
// plan at a time" can be enforced everywhere a plan gets assigned, not just the primary
|
// GYM-ACCESS plan at a time" is enforced everywhere a gym plan gets assigned, not just
|
||||||
// path.
|
// the primary path.
|
||||||
Task<bool> HasAnyActiveSubscriptionAsync(Guid customerId, CancellationToken ct = default);
|
Task<bool> HasActiveGymAccessPlanAsync(Guid customerId, CancellationToken ct = default);
|
||||||
|
|
||||||
Task<SubscriptionMember> AddSubscriptionMemberAsync(SubscriptionMember member);
|
Task<SubscriptionMember> AddSubscriptionMemberAsync(SubscriptionMember member);
|
||||||
Task<int> GetActiveMemberCountAsync(Guid subscriptionId);
|
Task<int> GetActiveMemberCountAsync(Guid subscriptionId);
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using Fitness_core_BE.Models;
|
||||||
|
|
||||||
|
namespace Fitness_core_BE.Interfaces
|
||||||
|
{
|
||||||
|
public interface IFacilityClassRepository
|
||||||
|
{
|
||||||
|
Task<FacilityClass> CreateFacilityClass(FacilityClass facilityClass);
|
||||||
|
Task<List<FacilityClass>> GetFacilityClasses(Guid clientId, bool includeInactive = false);
|
||||||
|
Task<FacilityClass?> GetFacilityClassById(Guid facilityClassId);
|
||||||
|
Task<FacilityClass> UpdateFacilityClass(FacilityClass facilityClass);
|
||||||
|
|
||||||
|
/// <summary>Active, non-Custom classes whose OccurrencesGeneratedThrough hasn't reached
|
||||||
|
/// the horizon yet -- Custom classes are never generated, their occurrences are always
|
||||||
|
/// written explicitly.</summary>
|
||||||
|
Task<List<FacilityClass>> GetClassesNeedingGenerationAsync(Guid clientId, DateTime horizon);
|
||||||
|
|
||||||
|
/// <summary>Existing occurrence start times for this class in [from, to) -- for
|
||||||
|
/// pre-generation dedup. The unique (FKFacilityClassId, StartTime) index is the actual
|
||||||
|
/// race-safety net; this just avoids the common sequential case needlessly retrying.</summary>
|
||||||
|
Task<HashSet<DateTime>> GetExistingOccurrenceStartTimesAsync(Guid facilityClassId, DateTime from, DateTime to);
|
||||||
|
|
||||||
|
Task AddOccurrencesAsync(IEnumerable<FacilitySession> occurrences);
|
||||||
|
|
||||||
|
Task<List<FacilitySession>> GetOccurrencesForClassAsync(Guid facilityClassId, bool includeCancelled);
|
||||||
|
|
||||||
|
/// <summary>Deletes occurrences that are both in the future and have zero Booking rows
|
||||||
|
/// of ANY status (a Cancelled booking still holds the FK, so it would fail the delete) --
|
||||||
|
/// the safe-to-regenerate set when a class's recurrence rule changes. Returns the count
|
||||||
|
/// deleted.</summary>
|
||||||
|
Task<int> DeleteFutureUnbookedOccurrencesAsync(Guid facilityClassId, DateTime nowUtc);
|
||||||
|
|
||||||
|
/// <summary>Confirmed, non-cancelled ad-hoc bookings whose window overlaps an active
|
||||||
|
/// (non-cancelled) class occurrence on the same facility -- surfaced to staff as a
|
||||||
|
/// conflict to resolve via the existing Reschedule flow, not auto-resolved.</summary>
|
||||||
|
Task<List<Booking>> GetSchedulingConflictsAsync(Guid clientId);
|
||||||
|
|
||||||
|
/// <summary>Links a newly-created CustomerPlan to the class it's sold against -- the
|
||||||
|
/// "Manage Plan" action's whole reason for being a class-scoped entry point rather than
|
||||||
|
/// the general Plans tab.</summary>
|
||||||
|
Task LinkPlanToClassAsync(Guid customerPlanId, Guid facilityClassId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,9 +12,14 @@ namespace Fitness_core_BE.Interfaces
|
|||||||
|
|
||||||
Task<FacilitySession> CreateFacilitySession(FacilitySession session);
|
Task<FacilitySession> CreateFacilitySession(FacilitySession session);
|
||||||
Task<FacilitySession?> GetFacilitySessionById(Guid facilitySessionId);
|
Task<FacilitySession?> GetFacilitySessionById(Guid facilitySessionId);
|
||||||
Task<List<FacilitySession>> GetSessions(Guid clientId, Guid? facilityId, DateTime fromUtc, DateTime toUtc);
|
Task<List<FacilitySession>> GetSessions(Guid clientId, Guid? facilityId, DateTime fromUtc, DateTime toUtc, bool includeCancelled = false);
|
||||||
Task<int> GetActiveBookingCount(Guid facilitySessionId);
|
Task<int> GetActiveBookingCount(Guid facilitySessionId);
|
||||||
Task<FacilitySession?> CancelFacilitySession(Guid facilitySessionId, Guid clientId);
|
Task<List<FacilitySession>> GetOverlappingActiveSessionsAsync(Guid facilityId, DateTime startUtc, DateTime endUtc);
|
||||||
|
|
||||||
|
/// <summary>"Mark not held" -- see FacilityService.CancelFacilitySession for the full
|
||||||
|
/// cascade this backs. Returns the session with its Bookings included so the caller can
|
||||||
|
/// cancel+refund each Confirmed one.</summary>
|
||||||
|
Task<FacilitySession?> MarkFacilitySessionNotHeldAsync(Guid facilitySessionId, Guid clientId, string? reason);
|
||||||
|
|
||||||
// Device binding (absorbed from the old IPlaceRepo -- one AccessDevice per Facility).
|
// Device binding (absorbed from the old IPlaceRepo -- one AccessDevice per Facility).
|
||||||
Task<AccessDevice?> GetDeviceByIdForClientAsync(Guid deviceId, Guid clientId);
|
Task<AccessDevice?> GetDeviceByIdForClientAsync(Guid deviceId, Guid clientId);
|
||||||
@@ -30,15 +35,15 @@ namespace Fitness_core_BE.Interfaces
|
|||||||
Task<Dictionary<Guid, List<Guid>>> GetFacilityIdsByPlanIdsAsync(List<Guid> customerPlanIds);
|
Task<Dictionary<Guid, List<Guid>>> GetFacilityIdsByPlanIdsAsync(List<Guid> customerPlanIds);
|
||||||
Task<Dictionary<Guid, string>> GetFacilityNamesByIdsAsync(List<Guid> facilityIds);
|
Task<Dictionary<Guid, string>> GetFacilityNamesByIdsAsync(List<Guid> facilityIds);
|
||||||
|
|
||||||
// AdHoc booking support (new). See TenantDbContext's Booking (FKFacilityId, StartTime,
|
// WalkIn booking support (new). See TenantDbContext's Booking (FKFacilityId, StartTime,
|
||||||
// EndTime) index comment -- overlap is checked here rather than by a DB constraint.
|
// EndTime) index comment -- overlap is checked here rather than by a DB constraint.
|
||||||
/// <summary>Counts non-cancelled/non-completed AdHoc bookings on this facility whose
|
/// <summary>Counts non-cancelled/non-completed WalkIn bookings on this facility whose
|
||||||
/// [StartTime, EndTime) overlaps the given window. Pass excludeBookingId when
|
/// [StartTime, EndTime) overlaps the given window. Pass excludeBookingId when
|
||||||
/// re-checking a booking being rescheduled, so it doesn't count against itself.</summary>
|
/// re-checking a booking being rescheduled, so it doesn't count against itself.</summary>
|
||||||
Task<int> GetOverlappingAdhocBookingCountAsync(Guid facilityId, DateTime startTime, DateTime endTime, Guid? excludeBookingId = null);
|
Task<int> GetOverlappingWalkInBookingCountAsync(Guid facilityId, DateTime startTime, DateTime endTime, Guid? excludeBookingId = null);
|
||||||
|
|
||||||
/// <summary>Issues a row lock on the facility (SELECT ... FOR UPDATE) inside the caller's
|
/// <summary>Issues a row lock on the facility (SELECT ... FOR UPDATE) inside the caller's
|
||||||
/// already-open unit-of-work transaction, serializing concurrent AdHoc-slot claims on the
|
/// already-open unit-of-work transaction, serializing concurrent WalkIn-slot claims on the
|
||||||
/// same facility so two overlapping bookings can't both pass the overlap-count check.</summary>
|
/// same facility so two overlapping bookings can't both pass the overlap-count check.</summary>
|
||||||
Task LockFacilityForUpdateAsync(Guid facilityId);
|
Task LockFacilityForUpdateAsync(Guid facilityId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
|
using Fitness_core_BE.Models;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
namespace Fitness_core_BE.Interfaces.Services
|
namespace Fitness_core_BE.Interfaces.Services
|
||||||
{
|
{
|
||||||
public interface IBookingService
|
public interface IBookingService
|
||||||
{
|
{
|
||||||
|
/// <summary>Shared cancel+refund core -- see BookingService.CancelBookingCore.</summary>
|
||||||
|
Task CancelBookingCore(Booking booking);
|
||||||
|
|
||||||
/// <summary>Logged-in member books a session against their own plan's allowance.</summary>
|
/// <summary>Logged-in member books a session against their own plan's allowance.</summary>
|
||||||
Task<object> CreateMemberBooking(object payload, HttpContext httpContext);
|
Task<object> CreateMemberBooking(object payload, HttpContext httpContext);
|
||||||
|
|
||||||
/// <summary>No membership at all -- pay-per-session, identity proof is the returned OTP.</summary>
|
/// <summary>No membership at all -- pay-per-session, identity proof is the returned OTP.</summary>
|
||||||
Task<object> CreateGuestBooking(object payload, HttpContext httpContext);
|
Task<object> CreateGuestBooking(object payload, HttpContext httpContext);
|
||||||
|
|
||||||
/// <summary>Member books an AdHoc-mode facility directly for an arbitrary time window, no
|
/// <summary>Member books a WalkIn-mode facility directly for an arbitrary time window, no
|
||||||
/// pre-created session.</summary>
|
/// pre-created session.</summary>
|
||||||
Task<object> CreateMemberAdhocBooking(object payload, HttpContext httpContext);
|
Task<object> CreateMemberWalkInBooking(object payload, HttpContext httpContext);
|
||||||
|
|
||||||
/// <summary>Guest/staff walk-in books an AdHoc-mode facility directly.</summary>
|
/// <summary>Guest/staff walk-in books a WalkIn-mode facility directly.</summary>
|
||||||
Task<object> CreateGuestAdhocBooking(object payload, HttpContext httpContext);
|
Task<object> CreateGuestWalkInBooking(object payload, HttpContext httpContext);
|
||||||
|
|
||||||
/// <summary>Renders the public, unauthenticated confirmation page a booking's ShareUrl
|
/// <summary>Renders the public, unauthenticated confirmation page a booking's ShareUrl
|
||||||
/// points to -- the "copy the link and share it" receipt. Returns null if not found, so
|
/// points to -- the "copy the link and share it" receipt. Returns null if not found, so
|
||||||
@@ -36,7 +40,7 @@ namespace Fitness_core_BE.Interfaces.Services
|
|||||||
/// no cancel/recreate needed. Guest bookings only.</summary>
|
/// no cancel/recreate needed. Guest bookings only.</summary>
|
||||||
Task<object> ReassignBooking(object payload, HttpContext httpContext);
|
Task<object> ReassignBooking(object payload, HttpContext httpContext);
|
||||||
|
|
||||||
/// <summary>Staff moves a Confirmed booking to a different time (AdHoc) or session
|
/// <summary>Staff moves a Confirmed booking to a different time (WalkIn) or session
|
||||||
/// (Scheduled), only onto a slot with room.</summary>
|
/// (Scheduled), only onto a slot with room.</summary>
|
||||||
Task<object> RescheduleBooking(object payload, HttpContext httpContext);
|
Task<object> RescheduleBooking(object payload, HttpContext httpContext);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace Fitness_core_BE.Interfaces.Services
|
||||||
|
{
|
||||||
|
public interface IClassService
|
||||||
|
{
|
||||||
|
Task<object> GetFacilityClasses(object payload, HttpContext httpContext);
|
||||||
|
Task<object> CreateFacilityClass(object payload, HttpContext httpContext);
|
||||||
|
Task<object> UpdateFacilityClass(object payload, HttpContext httpContext);
|
||||||
|
Task<object> GetOccurrences(object payload, HttpContext httpContext);
|
||||||
|
Task<object> GetSchedulingConflicts(object payload, HttpContext httpContext);
|
||||||
|
|
||||||
|
/// <summary>Lazy occurrence generation, mirroring the existing SweepOverdueBookingsAsync
|
||||||
|
/// convention (no background job) -- generates the missing FacilitySession rows up to
|
||||||
|
/// "today + 8 weeks" for every active, non-Custom class whose watermark hasn't reached
|
||||||
|
/// it yet, and advances the watermark. Called from the top of FacilityService.GetSessions,
|
||||||
|
/// the one read path everything already goes through.</summary>
|
||||||
|
Task EnsureOccurrencesGeneratedAsync(Guid clientId);
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-7
@@ -4,7 +4,7 @@ using System.ComponentModel.DataAnnotations.Schema;
|
|||||||
namespace Fitness_core_BE.Models
|
namespace Fitness_core_BE.Models
|
||||||
{
|
{
|
||||||
/// <summary>One reservation against a Facility — either a Scheduled-mode booking against a
|
/// <summary>One reservation against a Facility — either a Scheduled-mode booking against a
|
||||||
/// pre-created FacilitySession (many bookings share one capacity pool), or an AdHoc-mode
|
/// pre-created FacilitySession (many bookings share one capacity pool), or a WalkIn-mode
|
||||||
/// booking that claims one of the Facility's NumberOfUnits directly for an arbitrary time
|
/// booking that claims one of the Facility's NumberOfUnits directly for an arbitrary time
|
||||||
/// window, no session needed. Either an existing member using a CustomerPlan session
|
/// window, no session needed. Either an existing member using a CustomerPlan session
|
||||||
/// allowance (FKCustomerId set, IsGuest = false) or a non-member who booked online and pays
|
/// allowance (FKCustomerId set, IsGuest = false) or a non-member who booked online and pays
|
||||||
@@ -16,28 +16,28 @@ namespace Fitness_core_BE.Models
|
|||||||
[Key]
|
[Key]
|
||||||
public Guid BookingId { get; set; }
|
public Guid BookingId { get; set; }
|
||||||
|
|
||||||
/// <summary>Null for an AdHoc booking (no pre-created session).</summary>
|
/// <summary>Null for a WalkIn booking (no pre-created session).</summary>
|
||||||
public Guid? FKFacilitySessionId { get; set; }
|
public Guid? FKFacilitySessionId { get; set; }
|
||||||
[ForeignKey(nameof(FKFacilitySessionId))]
|
[ForeignKey(nameof(FKFacilitySessionId))]
|
||||||
public FacilitySession? FacilitySession { get; set; }
|
public FacilitySession? FacilitySession { get; set; }
|
||||||
|
|
||||||
/// <summary>Always set, for both modes -- copied from FacilitySession.FKFacilityId at
|
/// <summary>Always set, for both modes -- copied from FacilitySession.FKFacilityId at
|
||||||
/// creation (Scheduled) or the booked facility directly (AdHoc). Every place that needs
|
/// creation (Scheduled) or the booked facility directly (WalkIn). Every place that needs
|
||||||
/// "which facility / what time window" reads these (and StartTime/EndTime below)
|
/// "which facility / what time window" reads these (and StartTime/EndTime below)
|
||||||
/// uniformly instead of reaching through FacilitySession, which doesn't exist for AdHoc
|
/// uniformly instead of reaching through FacilitySession, which doesn't exist for WalkIn
|
||||||
/// bookings.</summary>
|
/// bookings.</summary>
|
||||||
public Guid FKFacilityId { get; set; }
|
public Guid FKFacilityId { get; set; }
|
||||||
[ForeignKey(nameof(FKFacilityId))]
|
[ForeignKey(nameof(FKFacilityId))]
|
||||||
public Facility Facility { get; set; }
|
public Facility Facility { get; set; }
|
||||||
|
|
||||||
/// <summary>Copied from FacilitySession.StartTime (Scheduled) or the requested start
|
/// <summary>Copied from FacilitySession.StartTime (Scheduled) or the requested start
|
||||||
/// (AdHoc) at creation; updated in place by Reschedule.</summary>
|
/// (WalkIn) at creation; updated in place by Reschedule.</summary>
|
||||||
public DateTime StartTime { get; set; }
|
public DateTime StartTime { get; set; }
|
||||||
/// <summary>Same pattern as StartTime.</summary>
|
/// <summary>Same pattern as StartTime.</summary>
|
||||||
public DateTime EndTime { get; set; }
|
public DateTime EndTime { get; set; }
|
||||||
|
|
||||||
/// <summary>Informational headcount for an AdHoc booking (e.g. "how many will come") --
|
/// <summary>Informational headcount for a WalkIn booking (e.g. "how many will come") --
|
||||||
/// never enforced as a capacity mechanic; each AdHoc booking claims one whole unit
|
/// never enforced as a capacity mechanic; each WalkIn booking claims one whole unit
|
||||||
/// regardless of party size. Null for Scheduled bookings.</summary>
|
/// regardless of party size. Null for Scheduled bookings.</summary>
|
||||||
public int? PartySize { get; set; }
|
public int? PartySize { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -63,5 +63,14 @@ namespace Fitness_core_BE.Models
|
|||||||
|
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>Meaningful only for a plan linked to one or more classes (via
|
||||||
|
/// CustomerPlanClass) -- never consulted for an ordinary plan with no class links,
|
||||||
|
/// which always grants gym access regardless of this flag's stored value. Lets a class
|
||||||
|
/// plan optionally also grant general gym-floor access ("class + gym" as one product)
|
||||||
|
/// instead of class-only. Migration default true (so every pre-existing plan, which has
|
||||||
|
/// no class links, keeps behaving exactly as before); new class plans default this to
|
||||||
|
/// false in the UI.</summary>
|
||||||
|
public bool GrantsGymAccess { get; set; } = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Fitness_core_BE.Models
|
||||||
|
{
|
||||||
|
/// <summary>Many-to-many join: which FacilityClass(es) a CustomerPlan is sold against. A
|
||||||
|
/// plan with no rows here is an ordinary gym plan, completely unaffected by the Classes
|
||||||
|
/// feature -- CustomerPlan.GrantsGymAccess is never consulted for it. Structurally identical
|
||||||
|
/// to CustomerPlanFacility.</summary>
|
||||||
|
public class CustomerPlanClass
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public Guid CustomerPlanClassId { get; set; }
|
||||||
|
|
||||||
|
public Guid FKCustomerPlanId { get; set; }
|
||||||
|
[ForeignKey(nameof(FKCustomerPlanId))]
|
||||||
|
public CustomerPlan CustomerPlan { get; set; } = null!;
|
||||||
|
|
||||||
|
public Guid FKFacilityClassId { get; set; }
|
||||||
|
[ForeignKey(nameof(FKFacilityClassId))]
|
||||||
|
public FacilityClass FacilityClass { get; set; } = null!;
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-11
@@ -21,25 +21,30 @@ namespace Fitness_core_BE.Models
|
|||||||
[MaxLength(128)]
|
[MaxLength(128)]
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>"Scheduled" | "AdHoc". Scheduled: staff pre-creates fixed-time
|
/// <summary>Staff pre-creates fixed-time FacilitySessions (classes) that many bookings
|
||||||
/// FacilitySessions that many bookings share one capacity pool against (classes).
|
/// share one capacity pool against. Independent of SupportsBooking -- both can be true,
|
||||||
/// AdHoc: a booking claims one of NumberOfUnits identical units directly for an
|
/// e.g. a court that hosts a scheduled class AND accepts ad-hoc walk-in bookings outside
|
||||||
/// arbitrary time window, no pre-created session needed (courts/pool).</summary>
|
/// class hours.</summary>
|
||||||
public string BookingMode { get; set; } = "Scheduled";
|
public bool SupportsClasses { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>A booking can claim one of NumberOfUnits identical units directly for an
|
||||||
|
/// arbitrary time window, no pre-created session needed (courts/pool). At least one of
|
||||||
|
/// SupportsClasses/SupportsBooking must be true.</summary>
|
||||||
|
public bool SupportsBooking { get; set; } = false;
|
||||||
|
|
||||||
[MaxLength(500)]
|
[MaxLength(500)]
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
|
|
||||||
/// <summary>Default per-session headcount limit when BookingMode == "Scheduled"; a
|
/// <summary>Default per-session headcount limit when SupportsClasses; a specific
|
||||||
/// specific FacilitySession can override.</summary>
|
/// FacilitySession can override.</summary>
|
||||||
public int DefaultCapacity { get; set; } = 1;
|
public int DefaultCapacity { get; set; } = 1;
|
||||||
|
|
||||||
/// <summary>Number of identical, independently-bookable units when BookingMode ==
|
/// <summary>Number of identical, independently-bookable units when SupportsBooking (e.g.
|
||||||
/// "AdHoc" (e.g. 3 for "3 courts") -- availability is this minus overlapping
|
/// 3 for "3 courts") -- availability is this minus overlapping non-cancelled/
|
||||||
/// non-cancelled/non-completed AdHoc bookings in the requested window.</summary>
|
/// non-completed WalkIn bookings in the requested window.</summary>
|
||||||
public int NumberOfUnits { get; set; } = 1;
|
public int NumberOfUnits { get; set; } = 1;
|
||||||
|
|
||||||
/// <summary>Hourly rate used to compute a guest AdHoc booking's suggested AmountCharged
|
/// <summary>Hourly rate used to compute a guest WalkIn booking's suggested AmountCharged
|
||||||
/// (PricePerHour * duration-in-hours) at creation time.</summary>
|
/// (PricePerHour * duration-in-hours) at creation time.</summary>
|
||||||
public decimal PricePerHour { get; set; } = 0m;
|
public decimal PricePerHour { get; set; } = 0m;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Fitness_core_BE.Models
|
||||||
|
{
|
||||||
|
/// <summary>A recurring class definition (Zumba, Yoga) on a Facility that SupportsClasses --
|
||||||
|
/// generates FacilitySession rows ("occurrences") on a schedule. Replaces manually creating
|
||||||
|
/// one-off FacilitySession rows from the Bookings page for anything that recurs.</summary>
|
||||||
|
public class FacilityClass
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public Guid FacilityClassId { get; set; }
|
||||||
|
|
||||||
|
public Guid FKClientId { get; set; }
|
||||||
|
[ForeignKey(nameof(FKClientId))]
|
||||||
|
public Client Client { get; set; }
|
||||||
|
|
||||||
|
public Guid FKFacilityId { get; set; }
|
||||||
|
[ForeignKey(nameof(FKFacilityId))]
|
||||||
|
public Facility Facility { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(500)]
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
/// <summary>"Daily" | "Weekly" | "Monthly" | "Custom". Custom skips every rule field
|
||||||
|
/// below -- occurrences are written explicitly (as FacilitySession rows) at creation/
|
||||||
|
/// edit time with their own dates, instead of being generated from a rule.</summary>
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string RecurrenceType { get; set; } = "Weekly";
|
||||||
|
|
||||||
|
/// <summary>The class's daily time window, in the facility's local time (not UTC) -- the
|
||||||
|
/// generator converts to UTC per-occurrence using the club's timezone. Unused (0) for
|
||||||
|
/// Custom, where each occurrence's own FacilitySession.StartTime/EndTime is explicit.</summary>
|
||||||
|
public TimeSpan StartTimeOfDay { get; set; }
|
||||||
|
public int DurationMinutes { get; set; } = 60;
|
||||||
|
|
||||||
|
/// <summary>Weekly only. Bit flags, Sunday = 1 (bit 0) through Saturday = 64 (bit 6).</summary>
|
||||||
|
public int WeekDaysMask { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>Monthly only. Comma-separated day-of-month list, e.g. "1,15".</summary>
|
||||||
|
[MaxLength(200)]
|
||||||
|
public string? MonthDays { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Unused for Custom (occurrences are explicit, not generated from a range).</summary>
|
||||||
|
public DateTime SeriesStartDate { get; set; } = DateTime.UtcNow.Date;
|
||||||
|
/// <summary>Null = ongoing, no end date. Unused for Custom.</summary>
|
||||||
|
public DateTime? SeriesEndDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Null = unlimited (see IsUnlimitedCapacity); otherwise the per-occurrence
|
||||||
|
/// headcount cap, same meaning as Facility.DefaultCapacity/FacilitySession.Capacity.</summary>
|
||||||
|
public int? Capacity { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When true, Capacity is ignored and every generated occurrence has no cap --
|
||||||
|
/// a distinct flag rather than a sentinel value, since FacilitySession.Capacity == null
|
||||||
|
/// already means "use the facility default," and int.MaxValue would overflow the
|
||||||
|
/// checked Sum() in FacilityService.GetAvailability.</summary>
|
||||||
|
public bool IsUnlimitedCapacity { get; set; } = false;
|
||||||
|
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Watermark: occurrences have been generated through this date already.
|
||||||
|
/// Advanced by FacilityService.EnsureOccurrencesGeneratedAsync; never consulted for
|
||||||
|
/// Custom classes (their occurrences are all written explicitly, never generated).</summary>
|
||||||
|
public DateTime OccurrencesGeneratedThrough { get; set; } = DateTime.UtcNow.Date;
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,12 +22,30 @@ namespace Fitness_core_BE.Models
|
|||||||
public DateTime StartTime { get; set; }
|
public DateTime StartTime { get; set; }
|
||||||
public DateTime EndTime { get; set; }
|
public DateTime EndTime { get; set; }
|
||||||
|
|
||||||
/// <summary>Overrides Facility.DefaultCapacity when set; null = use the facility default.</summary>
|
/// <summary>Overrides Facility.DefaultCapacity when set; null = use the facility default.
|
||||||
|
/// Ignored when IsUnlimitedCapacity is true.</summary>
|
||||||
public int? Capacity { get; set; }
|
public int? Capacity { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Copied from FacilityClass.IsUnlimitedCapacity at generation time (or set
|
||||||
|
/// directly for a manually-created session) -- see that flag's doc comment for why this
|
||||||
|
/// isn't just a Capacity sentinel value.</summary>
|
||||||
|
public bool IsUnlimitedCapacity { get; set; } = false;
|
||||||
|
|
||||||
public bool IsCancelled { get; set; } = false;
|
public bool IsCancelled { get; set; } = false;
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>Null for a manually-created session (the pre-Classes path, unaffected).
|
||||||
|
/// Set for a class occurrence -- which FacilityClass generated or explicitly added it.</summary>
|
||||||
|
public Guid? FKFacilityClassId { get; set; }
|
||||||
|
[ForeignKey(nameof(FKFacilityClassId))]
|
||||||
|
public FacilityClass? FacilityClass { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Set together by FacilityService's "mark not held" cascade (which replaces
|
||||||
|
/// the old bare-IsCancelled CancelFacilitySession) -- null means this occurrence is
|
||||||
|
/// either still scheduled or was never a class occurrence at all.</summary>
|
||||||
|
public string? NotHeldReason { get; set; }
|
||||||
|
public DateTime? NotHeldAt { get; set; }
|
||||||
|
|
||||||
public virtual ICollection<Booking> Bookings { get; set; }
|
public virtual ICollection<Booking> Bookings { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ builder.Services.AddScoped<IOverdueLockRepository, OverdueLockRepository>();
|
|||||||
builder.Services.AddScoped<IPlatformBillingRepository, PlatformBillingRepository>();
|
builder.Services.AddScoped<IPlatformBillingRepository, PlatformBillingRepository>();
|
||||||
builder.Services.AddScoped<ICustomerNotificationRepository, CustomerNotificationRepository>();
|
builder.Services.AddScoped<ICustomerNotificationRepository, CustomerNotificationRepository>();
|
||||||
builder.Services.AddScoped<IFacilityRepository, FacilityRepository>();
|
builder.Services.AddScoped<IFacilityRepository, FacilityRepository>();
|
||||||
|
builder.Services.AddScoped<IFacilityClassRepository, FacilityClassRepository>();
|
||||||
builder.Services.AddScoped<IBookingRepository, BookingRepository>();
|
builder.Services.AddScoped<IBookingRepository, BookingRepository>();
|
||||||
builder.Services.AddScoped<IInventoryRepository, InventoryRepository>();
|
builder.Services.AddScoped<IInventoryRepository, InventoryRepository>();
|
||||||
builder.Services.AddScoped<ITimeBankCardRepository, TimeBankCardRepository>();
|
builder.Services.AddScoped<ITimeBankCardRepository, TimeBankCardRepository>();
|
||||||
@@ -164,6 +165,7 @@ builder.Services.AddScoped<ICustomerInvoiceService, CustomerInvoiceService>();
|
|||||||
builder.Services.AddScoped<ICustomerSubscriptionService, CustomerSubscriptionService>();
|
builder.Services.AddScoped<ICustomerSubscriptionService, CustomerSubscriptionService>();
|
||||||
builder.Services.AddScoped<IFacilityService, FacilityService>();
|
builder.Services.AddScoped<IFacilityService, FacilityService>();
|
||||||
builder.Services.AddScoped<IBookingService, BookingService>();
|
builder.Services.AddScoped<IBookingService, BookingService>();
|
||||||
|
builder.Services.AddScoped<IClassService, ClassService>();
|
||||||
builder.Services.AddScoped<IInventoryService, InventoryService>();
|
builder.Services.AddScoped<IInventoryService, InventoryService>();
|
||||||
builder.Services.AddScoped<IRfidTagService, RfidTagService>();
|
builder.Services.AddScoped<IRfidTagService, RfidTagService>();
|
||||||
builder.Services.AddScoped<ITimeBankCardService, TimeBankCardService>();
|
builder.Services.AddScoped<ITimeBankCardService, TimeBankCardService>();
|
||||||
|
|||||||
@@ -354,12 +354,22 @@ namespace Fitness_core_BE.Repos
|
|||||||
// Unlike CustomerSubscription.FKCustomerId (which only ever points at the primary
|
// Unlike CustomerSubscription.FKCustomerId (which only ever points at the primary
|
||||||
// member), this resolves the caller's own SubscriptionMember row whether they're
|
// member), this resolves the caller's own SubscriptionMember row whether they're
|
||||||
// the primary or a secondary member of a group subscription.
|
// the primary or a secondary member of a group subscription.
|
||||||
|
// Both callers (ClientService.ChangeCustomerMembershipPlan,
|
||||||
|
// SubscriptionRenewalApprovalService.ChangePlanAsync) reject a Group-scope new plan and
|
||||||
|
// are conceptually "swap the member's individual gym plan" -- so this must resolve to
|
||||||
|
// their GYM-ACCESS subscription specifically, not just whichever active subscription
|
||||||
|
// happens to be most recently created. Without this filter, a member holding both a gym
|
||||||
|
// plan and a class-only plan would have their class subscription silently deactivated
|
||||||
|
// and replaced instead of their gym one, once a class subscription could outrank it by
|
||||||
|
// CreatedAt.
|
||||||
public async Task<SubscriptionMember?> GetActiveMembershipForCustomerAsync(Guid customerId, CancellationToken ct = default)
|
public async Task<SubscriptionMember?> GetActiveMembershipForCustomerAsync(Guid customerId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
return await _dbContext.SubscriptionMembers
|
return await _dbContext.SubscriptionMembers
|
||||||
.Include(m => m.Subscription)
|
.Include(m => m.Subscription)
|
||||||
.ThenInclude(s => s.CustomerPlan)
|
.ThenInclude(s => s.CustomerPlan)
|
||||||
.Where(m => m.FKCustomerId == customerId && m.IsActive && m.Subscription.IsActive)
|
.Where(m => m.FKCustomerId == customerId && m.IsActive && m.Subscription.IsActive)
|
||||||
|
.Where(m => !_dbContext.CustomerPlanClasses.Any(c => c.FKCustomerPlanId == m.Subscription.FKCustomerPlanId)
|
||||||
|
|| m.Subscription.CustomerPlan.GrantsGymAccess)
|
||||||
.OrderByDescending(m => m.Subscription.CreatedAt)
|
.OrderByDescending(m => m.Subscription.CreatedAt)
|
||||||
.FirstOrDefaultAsync(ct);
|
.FirstOrDefaultAsync(ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ namespace Fitness_core_BE.Repos
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Staff picking an existing member to link a walk-in booking to (identification only,
|
// Staff picking an existing member to link a walk-in booking to (identification only,
|
||||||
// see BookingService.CreateGuestBooking/CreateGuestAdhocBooking) -- tenant-scoped by
|
// see BookingService.CreateGuestBooking/CreateGuestWalkInBooking) -- tenant-scoped by
|
||||||
// construction, same as FindActiveLinkByIdentifierAsync above.
|
// construction, same as FindActiveLinkByIdentifierAsync above.
|
||||||
public Task<TenantCustomerLink?> GetTenantCustomerLinkById(Guid id)
|
public Task<TenantCustomerLink?> GetTenantCustomerLinkById(Guid id)
|
||||||
=> _context.TenantCustomerLink
|
=> _context.TenantCustomerLink
|
||||||
|
|||||||
@@ -134,23 +134,69 @@ namespace Fitness_core_BE.Repos
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<CustomerSubscription?> GetActiveSubscriptionByCustomerId(Guid customerId)
|
// A plan with no CustomerPlanClass links is an ordinary gym plan -- GrantsGymAccess is
|
||||||
|
// never consulted for it (always true in effect). A class-linked plan only grants gym
|
||||||
|
// access when GrantsGymAccess is explicitly set. Reused by every gym-access query below.
|
||||||
|
private IQueryable<CustomerPlan> GymAccessPlans() => _context.CustomerPlans.Where(p =>
|
||||||
|
!_context.CustomerPlanClasses.Any(c => c.FKCustomerPlanId == p.CustomerPlanId) || p.GrantsGymAccess);
|
||||||
|
|
||||||
|
public Task<bool> PlanGrantsGymAccessAsync(Guid customerPlanId, CancellationToken ct = default)
|
||||||
|
=> GymAccessPlans().AnyAsync(p => p.CustomerPlanId == customerPlanId, ct);
|
||||||
|
|
||||||
|
public async Task<CustomerSubscription?> GetActiveGymAccessSubscriptionAsync(Guid customerId)
|
||||||
{
|
{
|
||||||
return await _context.CustomerSubscriptions
|
return await _context.CustomerSubscriptions
|
||||||
.Include(s => s.TenantCustomerLink)
|
.Include(s => s.TenantCustomerLink)
|
||||||
.Include(s => s.CustomerPlan)
|
.Include(s => s.CustomerPlan)
|
||||||
.Where(s => s.FKCustomerId == customerId && s.IsActive)
|
.Where(s => s.FKCustomerId == customerId && s.IsActive)
|
||||||
|
.Where(s => GymAccessPlans().Any(p => p.CustomerPlanId == s.FKCustomerPlanId))
|
||||||
.OrderByDescending(s => s.CreatedAt)
|
.OrderByDescending(s => s.CreatedAt)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<CustomerSubscription?> GetActiveSubscriptionForPlanAsync(Guid customerId, Guid customerPlanId)
|
||||||
|
{
|
||||||
|
return await _context.CustomerSubscriptions
|
||||||
|
.Include(s => s.CustomerPlan)
|
||||||
|
.Where(s => s.FKCustomerId == customerId && s.IsActive && s.FKCustomerPlanId == customerPlanId)
|
||||||
|
.OrderByDescending(s => s.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Resolves which of the customer's (possibly several) active subscriptions
|
||||||
|
/// covers a specific class, preferring one that still has quota left this cycle over one
|
||||||
|
/// that's already exhausted it -- two different plans can link the same class.</summary>
|
||||||
|
public async Task<CustomerSubscription?> GetActiveSubscriptionForClassAsync(Guid customerId, Guid facilityClassId)
|
||||||
|
{
|
||||||
|
var candidates = await _context.CustomerSubscriptions
|
||||||
|
.Include(s => s.CustomerPlan)
|
||||||
|
.Where(s => s.FKCustomerId == customerId && s.IsActive)
|
||||||
|
.Where(s => _context.CustomerPlanClasses.Any(c =>
|
||||||
|
c.FKCustomerPlanId == s.FKCustomerPlanId && c.FKFacilityClassId == facilityClassId))
|
||||||
|
.OrderByDescending(s => s.CreatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
bool HasQuota(CustomerSubscription s)
|
||||||
|
{
|
||||||
|
if (!s.CustomerPlan.SessionsPerCycle.HasValue) return true;
|
||||||
|
var carriedOver = s.SessionsCarriedOverExpiresAt.HasValue && s.SessionsCarriedOverExpiresAt.Value < DateTime.UtcNow
|
||||||
|
? 0 : s.SessionsCarriedOver;
|
||||||
|
return s.SessionsUsedThisCycle < s.CustomerPlan.SessionsPerCycle.Value + carriedOver;
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates.FirstOrDefault(HasQuota) ?? candidates.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
// SubscriptionMembers always has a row for the primary too (CustomerSubscriptionFactory
|
// SubscriptionMembers always has a row for the primary too (CustomerSubscriptionFactory
|
||||||
// adds it alongside the CustomerSubscription itself), so this one query covers both
|
// adds it alongside the CustomerSubscription itself), so this one query covers both
|
||||||
// "primary somewhere" and "secondary/group member somewhere" in a single check.
|
// "primary somewhere" and "secondary/group member somewhere" in a single check. Scoped
|
||||||
public Task<bool> HasAnyActiveSubscriptionAsync(Guid customerId, CancellationToken ct = default)
|
// to gym-access plans only -- a class-only subscription never blocks a second one via
|
||||||
|
// this check (unlimited concurrent class-only subscriptions are allowed).
|
||||||
|
public Task<bool> HasActiveGymAccessPlanAsync(Guid customerId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
return _context.SubscriptionMembers.AnyAsync(m =>
|
return _context.SubscriptionMembers.AnyAsync(m =>
|
||||||
m.FKCustomerId == customerId && m.IsActive && m.Subscription.IsActive, ct);
|
m.FKCustomerId == customerId && m.IsActive && m.Subscription.IsActive &&
|
||||||
|
GymAccessPlans().Any(p => p.CustomerPlanId == m.Subscription.FKCustomerPlanId), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Group membership ----------
|
// ---------- Group membership ----------
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
using Fitness_core_BE.Interfaces;
|
||||||
|
using Fitness_core_BE.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Fitness_core_BE.Repos
|
||||||
|
{
|
||||||
|
public class FacilityClassRepository : IFacilityClassRepository
|
||||||
|
{
|
||||||
|
private readonly TenantDbContext _context;
|
||||||
|
|
||||||
|
public FacilityClassRepository(TenantDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<FacilityClass> CreateFacilityClass(FacilityClass facilityClass)
|
||||||
|
{
|
||||||
|
facilityClass.FacilityClassId = Guid.NewGuid();
|
||||||
|
facilityClass.CreatedAt = DateTime.UtcNow;
|
||||||
|
facilityClass.IsActive = true;
|
||||||
|
|
||||||
|
_context.FacilityClasses.Add(facilityClass);
|
||||||
|
return facilityClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<FacilityClass>> GetFacilityClasses(Guid clientId, bool includeInactive = false)
|
||||||
|
{
|
||||||
|
var query = _context.FacilityClasses
|
||||||
|
.Include(fc => fc.Facility)
|
||||||
|
.Where(fc => fc.FKClientId == clientId);
|
||||||
|
if (!includeInactive)
|
||||||
|
query = query.Where(fc => fc.IsActive);
|
||||||
|
|
||||||
|
return await query.OrderBy(fc => fc.Name).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<FacilityClass?> GetFacilityClassById(Guid facilityClassId)
|
||||||
|
=> _context.FacilityClasses
|
||||||
|
.Include(fc => fc.Facility)
|
||||||
|
.FirstOrDefaultAsync(fc => fc.FacilityClassId == facilityClassId);
|
||||||
|
|
||||||
|
public async Task<FacilityClass> UpdateFacilityClass(FacilityClass facilityClass)
|
||||||
|
{
|
||||||
|
var existing = await _context.FacilityClasses.FindAsync(facilityClass.FacilityClassId)
|
||||||
|
?? throw new KeyNotFoundException("Class not found");
|
||||||
|
|
||||||
|
existing.Name = facilityClass.Name;
|
||||||
|
existing.Description = facilityClass.Description;
|
||||||
|
existing.RecurrenceType = facilityClass.RecurrenceType;
|
||||||
|
existing.StartTimeOfDay = facilityClass.StartTimeOfDay;
|
||||||
|
existing.DurationMinutes = facilityClass.DurationMinutes;
|
||||||
|
existing.WeekDaysMask = facilityClass.WeekDaysMask;
|
||||||
|
existing.MonthDays = facilityClass.MonthDays;
|
||||||
|
existing.SeriesStartDate = facilityClass.SeriesStartDate;
|
||||||
|
existing.SeriesEndDate = facilityClass.SeriesEndDate;
|
||||||
|
existing.Capacity = facilityClass.Capacity;
|
||||||
|
existing.IsUnlimitedCapacity = facilityClass.IsUnlimitedCapacity;
|
||||||
|
existing.IsActive = facilityClass.IsActive;
|
||||||
|
existing.OccurrencesGeneratedThrough = facilityClass.OccurrencesGeneratedThrough;
|
||||||
|
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<FacilityClass>> GetClassesNeedingGenerationAsync(Guid clientId, DateTime horizon)
|
||||||
|
=> _context.FacilityClasses
|
||||||
|
.Where(fc => fc.FKClientId == clientId && fc.IsActive && fc.RecurrenceType != "Custom"
|
||||||
|
&& fc.OccurrencesGeneratedThrough < horizon)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<HashSet<DateTime>> GetExistingOccurrenceStartTimesAsync(Guid facilityClassId, DateTime from, DateTime to)
|
||||||
|
{
|
||||||
|
var times = await _context.FacilitySessions
|
||||||
|
.Where(s => s.FKFacilityClassId == facilityClassId && s.StartTime >= from && s.StartTime < to)
|
||||||
|
.Select(s => s.StartTime)
|
||||||
|
.ToListAsync();
|
||||||
|
return times.ToHashSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddOccurrencesAsync(IEnumerable<FacilitySession> occurrences)
|
||||||
|
{
|
||||||
|
var list = occurrences.ToList();
|
||||||
|
foreach (var occurrence in list)
|
||||||
|
{
|
||||||
|
occurrence.FacilitySessionId = Guid.NewGuid();
|
||||||
|
occurrence.CreatedAt = DateTime.UtcNow;
|
||||||
|
occurrence.IsCancelled = false;
|
||||||
|
}
|
||||||
|
await _context.FacilitySessions.AddRangeAsync(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<FacilitySession>> GetOccurrencesForClassAsync(Guid facilityClassId, bool includeCancelled)
|
||||||
|
{
|
||||||
|
var query = _context.FacilitySessions.Where(s => s.FKFacilityClassId == facilityClassId);
|
||||||
|
if (!includeCancelled)
|
||||||
|
query = query.Where(s => !s.IsCancelled);
|
||||||
|
|
||||||
|
return query.OrderByDescending(s => s.StartTime).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> DeleteFutureUnbookedOccurrencesAsync(Guid facilityClassId, DateTime nowUtc)
|
||||||
|
{
|
||||||
|
var candidates = await _context.FacilitySessions
|
||||||
|
.Where(s => s.FKFacilityClassId == facilityClassId && s.StartTime > nowUtc)
|
||||||
|
.Where(s => !_context.Bookings.Any(b => b.FKFacilitySessionId == s.FacilitySessionId))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
_context.FacilitySessions.RemoveRange(candidates);
|
||||||
|
return candidates.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task LinkPlanToClassAsync(Guid customerPlanId, Guid facilityClassId)
|
||||||
|
{
|
||||||
|
_context.CustomerPlanClasses.Add(new CustomerPlanClass
|
||||||
|
{
|
||||||
|
CustomerPlanClassId = Guid.NewGuid(),
|
||||||
|
FKCustomerPlanId = customerPlanId,
|
||||||
|
FKFacilityClassId = facilityClassId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<Booking>> GetSchedulingConflictsAsync(Guid clientId)
|
||||||
|
{
|
||||||
|
return _context.Bookings
|
||||||
|
.Include(b => b.Facility)
|
||||||
|
.Where(b => b.FKClientId == clientId && b.FKFacilitySessionId == null && b.Status == "Confirmed")
|
||||||
|
.Where(b => _context.FacilitySessions.Any(s =>
|
||||||
|
s.FKFacilityId == b.FKFacilityId && s.FKFacilityClassId != null && !s.IsCancelled &&
|
||||||
|
s.StartTime < b.EndTime && s.EndTime > b.StartTime))
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,8 @@ namespace Fitness_core_BE.Repos
|
|||||||
?? throw new KeyNotFoundException("Facility not found");
|
?? throw new KeyNotFoundException("Facility not found");
|
||||||
|
|
||||||
existing.Name = facility.Name;
|
existing.Name = facility.Name;
|
||||||
existing.BookingMode = facility.BookingMode;
|
existing.SupportsClasses = facility.SupportsClasses;
|
||||||
|
existing.SupportsBooking = facility.SupportsBooking;
|
||||||
existing.Description = facility.Description;
|
existing.Description = facility.Description;
|
||||||
existing.DefaultCapacity = facility.DefaultCapacity;
|
existing.DefaultCapacity = facility.DefaultCapacity;
|
||||||
existing.NumberOfUnits = facility.NumberOfUnits;
|
existing.NumberOfUnits = facility.NumberOfUnits;
|
||||||
@@ -76,12 +77,15 @@ namespace Fitness_core_BE.Repos
|
|||||||
.Include(s => s.Facility)
|
.Include(s => s.Facility)
|
||||||
.FirstOrDefaultAsync(s => s.FacilitySessionId == facilitySessionId);
|
.FirstOrDefaultAsync(s => s.FacilitySessionId == facilitySessionId);
|
||||||
|
|
||||||
public async Task<List<FacilitySession>> GetSessions(Guid clientId, Guid? facilityId, DateTime fromUtc, DateTime toUtc)
|
public async Task<List<FacilitySession>> GetSessions(Guid clientId, Guid? facilityId, DateTime fromUtc, DateTime toUtc, bool includeCancelled = false)
|
||||||
{
|
{
|
||||||
var query = _context.FacilitySessions
|
var query = _context.FacilitySessions
|
||||||
.Include(s => s.Facility)
|
.Include(s => s.Facility)
|
||||||
.Where(s => s.FKClientId == clientId && s.StartTime >= fromUtc && s.StartTime <= toUtc && !s.IsCancelled);
|
.Include(s => s.FacilityClass)
|
||||||
|
.Where(s => s.FKClientId == clientId && s.StartTime >= fromUtc && s.StartTime <= toUtc);
|
||||||
|
|
||||||
|
if (!includeCancelled)
|
||||||
|
query = query.Where(s => !s.IsCancelled);
|
||||||
if (facilityId.HasValue)
|
if (facilityId.HasValue)
|
||||||
query = query.Where(s => s.FKFacilityId == facilityId.Value);
|
query = query.Where(s => s.FKFacilityId == facilityId.Value);
|
||||||
|
|
||||||
@@ -91,13 +95,24 @@ namespace Fitness_core_BE.Repos
|
|||||||
public Task<int> GetActiveBookingCount(Guid facilitySessionId)
|
public Task<int> GetActiveBookingCount(Guid facilitySessionId)
|
||||||
=> _context.Bookings.CountAsync(b => b.FKFacilitySessionId == facilitySessionId && b.Status != "Cancelled");
|
=> _context.Bookings.CountAsync(b => b.FKFacilitySessionId == facilitySessionId && b.Status != "Cancelled");
|
||||||
|
|
||||||
public async Task<FacilitySession?> CancelFacilitySession(Guid facilitySessionId, Guid clientId)
|
/// <summary>True interval overlap (StartTime < end && EndTime > start), not a
|
||||||
|
/// "StartTime falls inside the window" filter like GetSessions -- a class that started
|
||||||
|
/// before the window and is still running must still count as occupying it.</summary>
|
||||||
|
public Task<List<FacilitySession>> GetOverlappingActiveSessionsAsync(Guid facilityId, DateTime startUtc, DateTime endUtc)
|
||||||
|
=> _context.FacilitySessions
|
||||||
|
.Where(s => s.FKFacilityId == facilityId && !s.IsCancelled && s.StartTime < endUtc && s.EndTime > startUtc)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<FacilitySession?> MarkFacilitySessionNotHeldAsync(Guid facilitySessionId, Guid clientId, string? reason)
|
||||||
{
|
{
|
||||||
var session = await _context.FacilitySessions
|
var session = await _context.FacilitySessions
|
||||||
|
.Include(s => s.Bookings)
|
||||||
.FirstOrDefaultAsync(s => s.FacilitySessionId == facilitySessionId && s.FKClientId == clientId);
|
.FirstOrDefaultAsync(s => s.FacilitySessionId == facilitySessionId && s.FKClientId == clientId);
|
||||||
if (session == null) return null;
|
if (session == null) return null;
|
||||||
|
|
||||||
session.IsCancelled = true;
|
session.IsCancelled = true;
|
||||||
|
session.NotHeldReason = reason;
|
||||||
|
session.NotHeldAt = DateTime.UtcNow;
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,13 +203,13 @@ namespace Fitness_core_BE.Repos
|
|||||||
.ToDictionary(g => g.Key, g => g.Select(pf => pf.FKFacilityId).ToList());
|
.ToDictionary(g => g.Key, g => g.Select(pf => pf.FKFacilityId).ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- AdHoc booking support (new) ---
|
// --- WalkIn booking support (new) ---
|
||||||
|
|
||||||
public Task<int> GetOverlappingAdhocBookingCountAsync(Guid facilityId, DateTime startTime, DateTime endTime, Guid? excludeBookingId = null)
|
public Task<int> GetOverlappingWalkInBookingCountAsync(Guid facilityId, DateTime startTime, DateTime endTime, Guid? excludeBookingId = null)
|
||||||
{
|
{
|
||||||
var query = _context.Bookings.Where(b =>
|
var query = _context.Bookings.Where(b =>
|
||||||
b.FKFacilityId == facilityId &&
|
b.FKFacilityId == facilityId &&
|
||||||
b.FKFacilitySessionId == null && // AdHoc bookings only
|
b.FKFacilitySessionId == null && // WalkIn bookings only
|
||||||
b.Status != "Cancelled" && b.Status != "NoShow" && b.Status != "Completed" &&
|
b.Status != "Cancelled" && b.Status != "NoShow" && b.Status != "Completed" &&
|
||||||
b.StartTime < endTime && b.EndTime > startTime);
|
b.StartTime < endTime && b.EndTime > startTime);
|
||||||
|
|
||||||
|
|||||||
+72
-31
@@ -50,7 +50,13 @@ namespace Fitness_core_BE.Services
|
|||||||
if (await _bookingRepository.CustomerHasActiveBookingForSession(facilitySessionId, cusId))
|
if (await _bookingRepository.CustomerHasActiveBookingForSession(facilitySessionId, cusId))
|
||||||
throw new ArgumentException("You already have a booking for this session");
|
throw new ArgumentException("You already have a booking for this session");
|
||||||
|
|
||||||
var subscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(cusId)
|
// A class occurrence (FKFacilityClassId set) must be covered by a subscription
|
||||||
|
// linked to that specific class; a legacy/manually-created session (null) falls
|
||||||
|
// back to the customer's gym-access subscription, same as before Classes existed.
|
||||||
|
var subscription = session.FKFacilityClassId.HasValue
|
||||||
|
? await _customerSubscriptionRepository.GetActiveSubscriptionForClassAsync(cusId, session.FKFacilityClassId.Value)
|
||||||
|
?? throw new ArgumentException("Your membership doesn't cover this class.")
|
||||||
|
: await _customerSubscriptionRepository.GetActiveGymAccessSubscriptionAsync(cusId)
|
||||||
?? throw new ArgumentException("No active membership plan found");
|
?? throw new ArgumentException("No active membership plan found");
|
||||||
|
|
||||||
// Null SessionsPerCycle = unlimited-access plan (existing gym behavior) --
|
// Null SessionsPerCycle = unlimited-access plan (existing gym behavior) --
|
||||||
@@ -70,10 +76,13 @@ namespace Fitness_core_BE.Services
|
|||||||
throw new ArgumentException("You've used all the sessions included in your current plan cycle");
|
throw new ArgumentException("You've used all the sessions included in your current plan cycle");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!session.IsUnlimitedCapacity)
|
||||||
|
{
|
||||||
var capacity = session.Capacity ?? session.Facility?.DefaultCapacity ?? 1;
|
var capacity = session.Capacity ?? session.Facility?.DefaultCapacity ?? 1;
|
||||||
var bookedCount = await _facilityRepository.GetActiveBookingCount(facilitySessionId);
|
var bookedCount = await _facilityRepository.GetActiveBookingCount(facilitySessionId);
|
||||||
if (bookedCount >= capacity)
|
if (bookedCount >= capacity)
|
||||||
throw new ArgumentException("This session is fully booked");
|
throw new ArgumentException("This session is fully booked");
|
||||||
|
}
|
||||||
|
|
||||||
var booking = new Booking
|
var booking = new Booking
|
||||||
{
|
{
|
||||||
@@ -124,10 +133,13 @@ namespace Fitness_core_BE.Services
|
|||||||
if (session.StartTime <= DateTime.UtcNow)
|
if (session.StartTime <= DateTime.UtcNow)
|
||||||
throw new ArgumentException("This session has already started");
|
throw new ArgumentException("This session has already started");
|
||||||
|
|
||||||
|
if (!session.IsUnlimitedCapacity)
|
||||||
|
{
|
||||||
var capacity = session.Capacity ?? session.Facility?.DefaultCapacity ?? 1;
|
var capacity = session.Capacity ?? session.Facility?.DefaultCapacity ?? 1;
|
||||||
var bookedCount = await _facilityRepository.GetActiveBookingCount(facilitySessionId);
|
var bookedCount = await _facilityRepository.GetActiveBookingCount(facilitySessionId);
|
||||||
if (bookedCount >= capacity)
|
if (bookedCount >= capacity)
|
||||||
throw new ArgumentException("This session is fully booked");
|
throw new ArgumentException("This session is fully booked");
|
||||||
|
}
|
||||||
|
|
||||||
var booking = new Booking
|
var booking = new Booking
|
||||||
{
|
{
|
||||||
@@ -161,14 +173,14 @@ namespace Fitness_core_BE.Services
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- AdHoc-mode creation (new) ----------
|
// ---------- WalkIn-mode creation (new) ----------
|
||||||
|
|
||||||
public async Task<object> CreateMemberAdhocBooking(object payload, HttpContext httpContext)
|
public async Task<object> CreateMemberWalkInBooking(object payload, HttpContext httpContext)
|
||||||
{
|
{
|
||||||
await _uow.BeginAsync();
|
await _uow.BeginAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var (facility, startTime, endTime, partySize, _) = await ValidateAdhocRequestAsync(payload);
|
var (facility, startTime, endTime, partySize, _) = await ValidateWalkInRequestAsync(payload);
|
||||||
|
|
||||||
var cusId = await _userRoleHelper.GetCusId(DataExtractor.GetUserIdFromContext(httpContext));
|
var cusId = await _userRoleHelper.GetCusId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
|
|
||||||
@@ -201,12 +213,12 @@ namespace Fitness_core_BE.Services
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<object> CreateGuestAdhocBooking(object payload, HttpContext httpContext)
|
public async Task<object> CreateGuestWalkInBooking(object payload, HttpContext httpContext)
|
||||||
{
|
{
|
||||||
await _uow.BeginAsync();
|
await _uow.BeginAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var (facility, startTime, endTime, partySize, pricePerHour) = await ValidateAdhocRequestAsync(payload);
|
var (facility, startTime, endTime, partySize, pricePerHour) = await ValidateWalkInRequestAsync(payload);
|
||||||
|
|
||||||
var data = DeserializePayload.Deserialize(payload);
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
var guestName = data["guestName"].GetString() ?? throw new ArgumentException("guestName is required");
|
var guestName = data["guestName"].GetString() ?? throw new ArgumentException("guestName is required");
|
||||||
@@ -249,11 +261,11 @@ namespace Fitness_core_BE.Services
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Shared validation for both AdHoc creation paths: facility must be AdHoc-mode,
|
/// <summary>Shared validation for both WalkIn creation paths: facility must support
|
||||||
/// requested window must have a free unit. Locks the facility row for the duration of the
|
/// walk-in booking, requested window must have a free unit. Locks the facility row for
|
||||||
/// caller's transaction so two concurrent claims on the same facility serialize instead of
|
/// the duration of the caller's transaction so two concurrent claims on the same facility
|
||||||
/// both reading "room available" before either commits.</summary>
|
/// serialize instead of both reading "room available" before either commits.</summary>
|
||||||
private async Task<(Facility facility, DateTime startTime, DateTime endTime, int? partySize, decimal? pricePerHour)> ValidateAdhocRequestAsync(object payload)
|
private async Task<(Facility facility, DateTime startTime, DateTime endTime, int? partySize, decimal? pricePerHour)> ValidateWalkInRequestAsync(object payload)
|
||||||
{
|
{
|
||||||
var data = DeserializePayload.Deserialize(payload);
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
var facilityId = Guid.Parse(data["facilityId"].GetString()!);
|
var facilityId = Guid.Parse(data["facilityId"].GetString()!);
|
||||||
@@ -276,13 +288,20 @@ namespace Fitness_core_BE.Services
|
|||||||
|
|
||||||
var facility = await _facilityRepository.GetFacilityById(facilityId)
|
var facility = await _facilityRepository.GetFacilityById(facilityId)
|
||||||
?? throw new KeyNotFoundException("Facility not found");
|
?? throw new KeyNotFoundException("Facility not found");
|
||||||
if (facility.BookingMode != "AdHoc")
|
if (!facility.SupportsBooking)
|
||||||
throw new ArgumentException("This facility does not support ad-hoc booking");
|
throw new ArgumentException("This facility does not support walk-in booking");
|
||||||
if (!facility.IsActive)
|
if (!facility.IsActive)
|
||||||
throw new ArgumentException("This facility is not active");
|
throw new ArgumentException("This facility is not active");
|
||||||
|
|
||||||
await _facilityRepository.LockFacilityForUpdateAsync(facilityId);
|
await _facilityRepository.LockFacilityForUpdateAsync(facilityId);
|
||||||
var overlapping = await _facilityRepository.GetOverlappingAdhocBookingCountAsync(facilityId, startTime, endTime);
|
|
||||||
|
// A running class occupies the whole facility, not just a seat -- reject the walk-in
|
||||||
|
// request outright rather than letting it compete for units.
|
||||||
|
var overlappingSessions = await _facilityRepository.GetOverlappingActiveSessionsAsync(facilityId, startTime, endTime);
|
||||||
|
if (overlappingSessions.Count > 0)
|
||||||
|
throw new ArgumentException("This facility is occupied by a scheduled class during the requested time");
|
||||||
|
|
||||||
|
var overlapping = await _facilityRepository.GetOverlappingWalkInBookingCountAsync(facilityId, startTime, endTime);
|
||||||
if (overlapping >= facility.NumberOfUnits)
|
if (overlapping >= facility.NumberOfUnits)
|
||||||
throw new ArgumentException("No units are free for the requested time");
|
throw new ArgumentException("No units are free for the requested time");
|
||||||
|
|
||||||
@@ -291,7 +310,7 @@ namespace Fitness_core_BE.Services
|
|||||||
|
|
||||||
/// <summary>Staff can optionally identify a walk-in guest as an already-registered member
|
/// <summary>Staff can optionally identify a walk-in guest as an already-registered member
|
||||||
/// (search-and-pick, not free-typed) -- purely for identification/auto-fill on the FE, see
|
/// (search-and-pick, not free-typed) -- purely for identification/auto-fill on the FE, see
|
||||||
/// CreateGuestBooking/CreateGuestAdhocBooking. Doesn't change IsGuest, PaymentStatus, or
|
/// CreateGuestBooking/CreateGuestWalkInBooking. Doesn't change IsGuest, PaymentStatus, or
|
||||||
/// any membership handling; FKCustomerId is just an informational link layered on top of
|
/// any membership handling; FKCustomerId is just an informational link layered on top of
|
||||||
/// an otherwise-ordinary guest booking.</summary>
|
/// an otherwise-ordinary guest booking.</summary>
|
||||||
private async Task<Guid?> ResolveOptionalCustomerLinkAsync(Dictionary<string, JsonElement> data)
|
private async Task<Guid?> ResolveOptionalCustomerLinkAsync(Dictionary<string, JsonElement> data)
|
||||||
@@ -448,18 +467,7 @@ namespace Fitness_core_BE.Services
|
|||||||
if (booking.Status == "CheckedIn")
|
if (booking.Status == "CheckedIn")
|
||||||
throw new ArgumentException("This booking has already been checked in and can't be cancelled");
|
throw new ArgumentException("This booking has already been checked in and can't be cancelled");
|
||||||
|
|
||||||
booking.Status = "Cancelled";
|
await CancelBookingCore(booking);
|
||||||
|
|
||||||
// Give the session allowance back -- only member bookings draw one down.
|
|
||||||
if (!booking.IsGuest && booking.FKCustomerSubscriptionId.HasValue)
|
|
||||||
{
|
|
||||||
var subscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(booking.FKCustomerId!.Value);
|
|
||||||
if (subscription != null && subscription.SubscriptionId == booking.FKCustomerSubscriptionId.Value
|
|
||||||
&& subscription.CustomerPlan.SessionsPerCycle.HasValue && subscription.SessionsUsedThisCycle > 0)
|
|
||||||
{
|
|
||||||
subscription.SessionsUsedThisCycle--;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await _uow.CommitAsync();
|
await _uow.CommitAsync();
|
||||||
return MapToDto(booking, includeOtp: false);
|
return MapToDto(booking, includeOtp: false);
|
||||||
@@ -471,6 +479,29 @@ namespace Fitness_core_BE.Services
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Just the in-memory mutations (status + subscription allowance refund), no
|
||||||
|
/// transaction demarcation or status validation -- called both by CancelBooking (after
|
||||||
|
/// its own status checks) and by FacilityService's "mark not held" cascade (which does
|
||||||
|
/// its own per-booking filtering before calling this for each affected booking), so
|
||||||
|
/// there's one refund code path instead of two. Looks the subscription up by the exact
|
||||||
|
/// id the booking drew its allowance from, rather than re-resolving "the customer's
|
||||||
|
/// current active subscription" -- correct regardless of which kind of subscription
|
||||||
|
/// (gym-access or class-linked) it was, and unaffected by the customer holding others.</summary>
|
||||||
|
public async Task CancelBookingCore(Booking booking)
|
||||||
|
{
|
||||||
|
booking.Status = "Cancelled";
|
||||||
|
|
||||||
|
// Give the session allowance back -- only member bookings draw one down.
|
||||||
|
if (!booking.IsGuest && booking.FKCustomerSubscriptionId.HasValue)
|
||||||
|
{
|
||||||
|
var subscription = await _customerSubscriptionRepository.GetCustomerSubscriptionById(booking.FKCustomerSubscriptionId.Value);
|
||||||
|
if (subscription != null && subscription.CustomerPlan.SessionsPerCycle.HasValue && subscription.SessionsUsedThisCycle > 0)
|
||||||
|
{
|
||||||
|
subscription.SessionsUsedThisCycle--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<object> ReassignBooking(object payload, HttpContext httpContext)
|
public async Task<object> ReassignBooking(object payload, HttpContext httpContext)
|
||||||
{
|
{
|
||||||
await _uow.BeginAsync();
|
await _uow.BeginAsync();
|
||||||
@@ -514,9 +545,9 @@ namespace Fitness_core_BE.Services
|
|||||||
|
|
||||||
if (booking.FKFacilitySessionId == null)
|
if (booking.FKFacilitySessionId == null)
|
||||||
{
|
{
|
||||||
// AdHoc: move to a new time on the same facility.
|
// WalkIn: move to a new time on the same facility.
|
||||||
if (!data.ContainsKey("startTime") || !data.ContainsKey("durationMinutes"))
|
if (!data.ContainsKey("startTime") || !data.ContainsKey("durationMinutes"))
|
||||||
throw new ArgumentException("startTime and durationMinutes are required to reschedule an ad-hoc booking");
|
throw new ArgumentException("startTime and durationMinutes are required to reschedule a walk-in booking");
|
||||||
|
|
||||||
var startTime = DateTime.Parse(data["startTime"].GetString()!).ToUniversalTime();
|
var startTime = DateTime.Parse(data["startTime"].GetString()!).ToUniversalTime();
|
||||||
var durationMinutes = data["durationMinutes"].GetInt32();
|
var durationMinutes = data["durationMinutes"].GetInt32();
|
||||||
@@ -524,7 +555,15 @@ namespace Fitness_core_BE.Services
|
|||||||
var endTime = startTime.AddMinutes(durationMinutes);
|
var endTime = startTime.AddMinutes(durationMinutes);
|
||||||
|
|
||||||
await _facilityRepository.LockFacilityForUpdateAsync(booking.FKFacilityId);
|
await _facilityRepository.LockFacilityForUpdateAsync(booking.FKFacilityId);
|
||||||
var overlapping = await _facilityRepository.GetOverlappingAdhocBookingCountAsync(
|
|
||||||
|
// Same class-occupancy guard as a fresh walk-in request -- otherwise staff
|
||||||
|
// could reschedule straight into a scheduled class's time.
|
||||||
|
var overlappingSessions = await _facilityRepository.GetOverlappingActiveSessionsAsync(
|
||||||
|
booking.FKFacilityId, startTime, endTime);
|
||||||
|
if (overlappingSessions.Count > 0)
|
||||||
|
throw new ArgumentException("This facility is occupied by a scheduled class during the requested time");
|
||||||
|
|
||||||
|
var overlapping = await _facilityRepository.GetOverlappingWalkInBookingCountAsync(
|
||||||
booking.FKFacilityId, startTime, endTime, excludeBookingId: booking.BookingId);
|
booking.FKFacilityId, startTime, endTime, excludeBookingId: booking.BookingId);
|
||||||
|
|
||||||
var facility = booking.Facility ?? await _facilityRepository.GetFacilityById(booking.FKFacilityId)
|
var facility = booking.Facility ?? await _facilityRepository.GetFacilityById(booking.FKFacilityId)
|
||||||
@@ -777,7 +816,9 @@ namespace Fitness_core_BE.Services
|
|||||||
FacilitySessionId = booking.FKFacilitySessionId,
|
FacilitySessionId = booking.FKFacilitySessionId,
|
||||||
FacilityId = booking.FKFacilityId,
|
FacilityId = booking.FKFacilityId,
|
||||||
FacilityName = booking.Facility?.Name,
|
FacilityName = booking.Facility?.Name,
|
||||||
BookingMode = booking.Facility?.BookingMode ?? (booking.FKFacilitySessionId.HasValue ? "Scheduled" : "AdHoc"),
|
// Per-booking fact (which path created it) -- a facility can support both modes now,
|
||||||
|
// so this no longer derives from the facility at all.
|
||||||
|
BookingMode = booking.FKFacilitySessionId.HasValue ? "Scheduled" : "WalkIn",
|
||||||
StartTime = booking.StartTime,
|
StartTime = booking.StartTime,
|
||||||
EndTime = booking.EndTime,
|
EndTime = booking.EndTime,
|
||||||
PartySize = booking.PartySize,
|
PartySize = booking.PartySize,
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
using Fitness_core_BE.DTOs.Facility;
|
||||||
|
using Fitness_core_BE.Helpers;
|
||||||
|
using Fitness_core_BE.Infra.UoW;
|
||||||
|
using Fitness_core_BE.Interfaces;
|
||||||
|
using Fitness_core_BE.Interfaces.Services;
|
||||||
|
using Fitness_core_BE.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Fitness_core_BE.Services
|
||||||
|
{
|
||||||
|
public class ClassService : IClassService
|
||||||
|
{
|
||||||
|
private static readonly string[] ValidRecurrenceTypes = ["Daily", "Weekly", "Monthly", "Custom"];
|
||||||
|
private const int GenerationHorizonDays = 56; // 8 weeks
|
||||||
|
|
||||||
|
private readonly IFacilityClassRepository _classRepository;
|
||||||
|
private readonly IFacilityRepository _facilityRepository;
|
||||||
|
private readonly IUnitOfWork _uow;
|
||||||
|
private readonly UserRoleHelper _userRoleHelper;
|
||||||
|
|
||||||
|
public ClassService(
|
||||||
|
IFacilityClassRepository classRepository,
|
||||||
|
IFacilityRepository facilityRepository,
|
||||||
|
IUnitOfWork uow,
|
||||||
|
UserRoleHelper userRoleHelper)
|
||||||
|
{
|
||||||
|
_classRepository = classRepository;
|
||||||
|
_facilityRepository = facilityRepository;
|
||||||
|
_uow = uow;
|
||||||
|
_userRoleHelper = userRoleHelper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<object> GetFacilityClasses(object payload, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
|
bool includeInactive = data.ContainsKey("includeInactive") && data["includeInactive"].GetBoolean();
|
||||||
|
|
||||||
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
|
var classes = await _classRepository.GetFacilityClasses(clientId, includeInactive);
|
||||||
|
|
||||||
|
return classes.Select(MapToDto).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<object> CreateFacilityClass(object payload, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
await _uow.BeginAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
|
|
||||||
|
var facilityId = Guid.Parse(data["facilityId"].GetString()!);
|
||||||
|
var facility = await _facilityRepository.GetFacilityById(facilityId);
|
||||||
|
if (facility == null || facility.FKClientId != clientId)
|
||||||
|
throw new KeyNotFoundException("Facility not found");
|
||||||
|
if (!facility.SupportsClasses)
|
||||||
|
throw new ArgumentException("This facility does not support classes");
|
||||||
|
|
||||||
|
if (!data.ContainsKey("name")) throw new KeyNotFoundException("name is required");
|
||||||
|
|
||||||
|
var recurrenceType = data.ContainsKey("recurrenceType") ? data["recurrenceType"].GetString() ?? "Weekly" : "Weekly";
|
||||||
|
if (!ValidRecurrenceTypes.Contains(recurrenceType))
|
||||||
|
throw new ArgumentException($"recurrenceType must be one of: {string.Join(", ", ValidRecurrenceTypes)}");
|
||||||
|
|
||||||
|
var isUnlimitedCapacity = data.ContainsKey("isUnlimitedCapacity") && data["isUnlimitedCapacity"].GetBoolean();
|
||||||
|
int? capacity = data.ContainsKey("capacity") && data["capacity"].ValueKind != JsonValueKind.Null
|
||||||
|
? data["capacity"].GetInt32() : null;
|
||||||
|
if (!isUnlimitedCapacity && capacity.HasValue && capacity.Value < 1)
|
||||||
|
throw new ArgumentException("capacity must be at least 1 when set");
|
||||||
|
|
||||||
|
var durationMinutes = data.ContainsKey("durationMinutes") ? data["durationMinutes"].GetInt32() : 60;
|
||||||
|
if (durationMinutes < 1) throw new ArgumentException("durationMinutes must be at least 1");
|
||||||
|
|
||||||
|
var facilityClass = new FacilityClass
|
||||||
|
{
|
||||||
|
FKClientId = clientId,
|
||||||
|
FKFacilityId = facilityId,
|
||||||
|
Name = data["name"].GetString() ?? "",
|
||||||
|
Description = data.ContainsKey("description") ? data["description"].GetString() : null,
|
||||||
|
RecurrenceType = recurrenceType,
|
||||||
|
DurationMinutes = durationMinutes,
|
||||||
|
Capacity = isUnlimitedCapacity ? null : capacity,
|
||||||
|
IsUnlimitedCapacity = isUnlimitedCapacity
|
||||||
|
};
|
||||||
|
|
||||||
|
if (recurrenceType == "Custom")
|
||||||
|
{
|
||||||
|
// Occurrences are explicit -- no rule fields, no generation watermark ever
|
||||||
|
// consulted for this class.
|
||||||
|
facilityClass.OccurrencesGeneratedThrough = DateTime.UtcNow.Date;
|
||||||
|
var result = await _classRepository.CreateFacilityClass(facilityClass);
|
||||||
|
await _uow.SaveChangesAsync(); // persist the id before occurrences reference it
|
||||||
|
|
||||||
|
await AddCustomOccurrencesAsync(facilityClass, data);
|
||||||
|
|
||||||
|
await _uow.CommitAsync();
|
||||||
|
result.Facility = facility;
|
||||||
|
return MapToDto(result);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!data.ContainsKey("startTimeOfDay")) throw new KeyNotFoundException("startTimeOfDay is required");
|
||||||
|
facilityClass.StartTimeOfDay = TimeSpan.Parse(data["startTimeOfDay"].GetString()!);
|
||||||
|
facilityClass.WeekDaysMask = data.ContainsKey("weekDaysMask") ? data["weekDaysMask"].GetInt32() : 0;
|
||||||
|
facilityClass.MonthDays = data.ContainsKey("monthDays") ? data["monthDays"].GetString() : null;
|
||||||
|
facilityClass.SeriesStartDate = data.ContainsKey("seriesStartDate") && data["seriesStartDate"].ValueKind != JsonValueKind.Null
|
||||||
|
? DateTime.Parse(data["seriesStartDate"].GetString()!).Date
|
||||||
|
: DateTime.UtcNow.Date;
|
||||||
|
facilityClass.SeriesEndDate = data.ContainsKey("seriesEndDate") && data["seriesEndDate"].ValueKind != JsonValueKind.Null
|
||||||
|
? DateTime.Parse(data["seriesEndDate"].GetString()!).Date
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (recurrenceType == "Weekly" && facilityClass.WeekDaysMask == 0)
|
||||||
|
throw new ArgumentException("Select at least one day of the week");
|
||||||
|
if (recurrenceType == "Monthly" && string.IsNullOrWhiteSpace(facilityClass.MonthDays))
|
||||||
|
throw new ArgumentException("Select at least one day of the month");
|
||||||
|
|
||||||
|
// Nothing generated yet -- the next GetSessions call does it lazily.
|
||||||
|
facilityClass.OccurrencesGeneratedThrough = facilityClass.SeriesStartDate.AddDays(-1);
|
||||||
|
|
||||||
|
var result = await _classRepository.CreateFacilityClass(facilityClass);
|
||||||
|
await _uow.CommitAsync();
|
||||||
|
result.Facility = facility;
|
||||||
|
return MapToDto(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await _uow.RollbackAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<object> UpdateFacilityClass(object payload, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
await _uow.BeginAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
|
var facilityClassId = Guid.Parse(data["facilityClassId"].GetString()!);
|
||||||
|
|
||||||
|
var existing = await _classRepository.GetFacilityClassById(facilityClassId)
|
||||||
|
?? throw new KeyNotFoundException("Class not found");
|
||||||
|
|
||||||
|
if (data.ContainsKey("name")) existing.Name = data["name"].GetString() ?? existing.Name;
|
||||||
|
if (data.ContainsKey("description")) existing.Description = data["description"].GetString();
|
||||||
|
if (data.ContainsKey("isActive")) existing.IsActive = data["isActive"].GetBoolean();
|
||||||
|
|
||||||
|
if (data.ContainsKey("isUnlimitedCapacity")) existing.IsUnlimitedCapacity = data["isUnlimitedCapacity"].GetBoolean();
|
||||||
|
if (data.ContainsKey("capacity"))
|
||||||
|
{
|
||||||
|
var capacity = data["capacity"].ValueKind != JsonValueKind.Null ? data["capacity"].GetInt32() : (int?)null;
|
||||||
|
if (!existing.IsUnlimitedCapacity && capacity.HasValue && capacity.Value < 1)
|
||||||
|
throw new ArgumentException("capacity must be at least 1 when set");
|
||||||
|
existing.Capacity = existing.IsUnlimitedCapacity ? null : capacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only these recurrence-rule fields trigger a regeneration -- editing the name,
|
||||||
|
// description, capacity or active flag doesn't touch existing occurrences.
|
||||||
|
var recurrenceKeys = new[] { "recurrenceType", "startTimeOfDay", "durationMinutes", "weekDaysMask", "monthDays", "seriesStartDate", "seriesEndDate" };
|
||||||
|
var recurrenceChanged = recurrenceKeys.Any(data.ContainsKey);
|
||||||
|
|
||||||
|
if (recurrenceChanged && existing.RecurrenceType != "Custom")
|
||||||
|
{
|
||||||
|
if (data.ContainsKey("recurrenceType"))
|
||||||
|
{
|
||||||
|
var recurrenceType = data["recurrenceType"].GetString() ?? existing.RecurrenceType;
|
||||||
|
if (!ValidRecurrenceTypes.Contains(recurrenceType))
|
||||||
|
throw new ArgumentException($"recurrenceType must be one of: {string.Join(", ", ValidRecurrenceTypes)}");
|
||||||
|
existing.RecurrenceType = recurrenceType;
|
||||||
|
}
|
||||||
|
if (data.ContainsKey("startTimeOfDay")) existing.StartTimeOfDay = TimeSpan.Parse(data["startTimeOfDay"].GetString()!);
|
||||||
|
if (data.ContainsKey("durationMinutes"))
|
||||||
|
{
|
||||||
|
var durationMinutes = data["durationMinutes"].GetInt32();
|
||||||
|
if (durationMinutes < 1) throw new ArgumentException("durationMinutes must be at least 1");
|
||||||
|
existing.DurationMinutes = durationMinutes;
|
||||||
|
}
|
||||||
|
if (data.ContainsKey("weekDaysMask")) existing.WeekDaysMask = data["weekDaysMask"].GetInt32();
|
||||||
|
if (data.ContainsKey("monthDays")) existing.MonthDays = data["monthDays"].GetString();
|
||||||
|
if (data.ContainsKey("seriesStartDate") && data["seriesStartDate"].ValueKind != JsonValueKind.Null)
|
||||||
|
existing.SeriesStartDate = DateTime.Parse(data["seriesStartDate"].GetString()!).Date;
|
||||||
|
if (data.ContainsKey("seriesEndDate"))
|
||||||
|
existing.SeriesEndDate = data["seriesEndDate"].ValueKind != JsonValueKind.Null
|
||||||
|
? DateTime.Parse(data["seriesEndDate"].GetString()!).Date : null;
|
||||||
|
|
||||||
|
if (existing.RecurrenceType == "Weekly" && existing.WeekDaysMask == 0)
|
||||||
|
throw new ArgumentException("Select at least one day of the week");
|
||||||
|
if (existing.RecurrenceType == "Monthly" && string.IsNullOrWhiteSpace(existing.MonthDays))
|
||||||
|
throw new ArgumentException("Select at least one day of the month");
|
||||||
|
|
||||||
|
// Anything past or already booked is left alone -- only future, unbooked
|
||||||
|
// occurrences are cleared so the next read regenerates them under the new rule.
|
||||||
|
await _classRepository.DeleteFutureUnbookedOccurrencesAsync(existing.FacilityClassId, DateTime.UtcNow);
|
||||||
|
existing.OccurrencesGeneratedThrough = existing.SeriesStartDate > DateTime.UtcNow.Date
|
||||||
|
? existing.SeriesStartDate.AddDays(-1)
|
||||||
|
: DateTime.UtcNow.Date.AddDays(-1);
|
||||||
|
}
|
||||||
|
else if (existing.RecurrenceType == "Custom" && data.ContainsKey("addCustomOccurrences"))
|
||||||
|
{
|
||||||
|
await AddCustomOccurrencesAsync(existing, data, "addCustomOccurrences");
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _classRepository.UpdateFacilityClass(existing);
|
||||||
|
await _uow.CommitAsync();
|
||||||
|
return MapToDto(result);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await _uow.RollbackAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<object> GetOccurrences(object payload, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
|
var facilityClassId = Guid.Parse(data["facilityClassId"].GetString()!);
|
||||||
|
bool includeCancelled = data.ContainsKey("includeCancelled") && data["includeCancelled"].GetBoolean();
|
||||||
|
|
||||||
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
|
var facilityClass = await _classRepository.GetFacilityClassById(facilityClassId);
|
||||||
|
if (facilityClass == null || facilityClass.FKClientId != clientId)
|
||||||
|
throw new KeyNotFoundException("Class not found");
|
||||||
|
|
||||||
|
var occurrences = await _classRepository.GetOccurrencesForClassAsync(facilityClassId, includeCancelled);
|
||||||
|
return occurrences.Select(o => new
|
||||||
|
{
|
||||||
|
facilitySessionId = o.FacilitySessionId,
|
||||||
|
startTime = o.StartTime,
|
||||||
|
endTime = o.EndTime,
|
||||||
|
capacity = o.Capacity,
|
||||||
|
isUnlimitedCapacity = o.IsUnlimitedCapacity,
|
||||||
|
isCancelled = o.IsCancelled,
|
||||||
|
notHeldReason = o.NotHeldReason,
|
||||||
|
notHeldAt = o.NotHeldAt
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<object> GetSchedulingConflicts(object payload, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
|
var conflicts = await _classRepository.GetSchedulingConflictsAsync(clientId);
|
||||||
|
|
||||||
|
return conflicts.Select(b => new SchedulingConflictDto
|
||||||
|
{
|
||||||
|
BookingId = b.BookingId,
|
||||||
|
FacilityId = b.FKFacilityId,
|
||||||
|
FacilityName = b.Facility?.Name,
|
||||||
|
GuestName = b.IsGuest ? b.GuestName : null,
|
||||||
|
StartTime = b.StartTime,
|
||||||
|
EndTime = b.EndTime
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task EnsureOccurrencesGeneratedAsync(Guid clientId)
|
||||||
|
{
|
||||||
|
var horizon = DateTime.UtcNow.Date.AddDays(GenerationHorizonDays);
|
||||||
|
var classes = await _classRepository.GetClassesNeedingGenerationAsync(clientId, horizon);
|
||||||
|
if (classes.Count == 0) return;
|
||||||
|
|
||||||
|
await _uow.BeginAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var facilityClass in classes)
|
||||||
|
await GenerateOccurrencesForClassAsync(facilityClass, horizon);
|
||||||
|
|
||||||
|
await _uow.CommitAsync();
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
// Two concurrent calendar loads raced to generate the same occurrence -- the
|
||||||
|
// unique (FKFacilityClassId, StartTime) index caught it. Roll back this whole
|
||||||
|
// batch rather than partially applying it; the next read regenerates safely
|
||||||
|
// (watermarks simply won't have advanced this one time).
|
||||||
|
await _uow.RollbackAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task GenerateOccurrencesForClassAsync(FacilityClass facilityClass, DateTime horizon)
|
||||||
|
{
|
||||||
|
var genFrom = facilityClass.OccurrencesGeneratedThrough > facilityClass.SeriesStartDate
|
||||||
|
? facilityClass.OccurrencesGeneratedThrough.AddDays(1)
|
||||||
|
: facilityClass.SeriesStartDate;
|
||||||
|
var genTo = facilityClass.SeriesEndDate.HasValue && facilityClass.SeriesEndDate.Value < horizon
|
||||||
|
? facilityClass.SeriesEndDate.Value
|
||||||
|
: horizon;
|
||||||
|
|
||||||
|
if (genFrom > genTo)
|
||||||
|
{
|
||||||
|
facilityClass.OccurrencesGeneratedThrough = horizon;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingStartTimes = await _classRepository.GetExistingOccurrenceStartTimesAsync(
|
||||||
|
facilityClass.FacilityClassId,
|
||||||
|
LocalToUtc(genFrom),
|
||||||
|
LocalToUtc(genTo.AddDays(1)));
|
||||||
|
|
||||||
|
var monthDays = !string.IsNullOrWhiteSpace(facilityClass.MonthDays)
|
||||||
|
? facilityClass.MonthDays.Split(',').Select(d => int.Parse(d.Trim())).ToHashSet()
|
||||||
|
: new HashSet<int>();
|
||||||
|
|
||||||
|
var toAdd = new List<FacilitySession>();
|
||||||
|
for (var date = genFrom.Date; date <= genTo.Date; date = date.AddDays(1))
|
||||||
|
{
|
||||||
|
bool runsToday = facilityClass.RecurrenceType switch
|
||||||
|
{
|
||||||
|
"Daily" => true,
|
||||||
|
"Weekly" => (facilityClass.WeekDaysMask & (1 << (int)date.DayOfWeek)) != 0,
|
||||||
|
"Monthly" => monthDays.Contains(date.Day),
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
if (!runsToday) continue;
|
||||||
|
|
||||||
|
var utcStart = LocalToUtc(date.Date + facilityClass.StartTimeOfDay);
|
||||||
|
if (existingStartTimes.Contains(utcStart)) continue;
|
||||||
|
|
||||||
|
toAdd.Add(new FacilitySession
|
||||||
|
{
|
||||||
|
FKFacilityId = facilityClass.FKFacilityId,
|
||||||
|
FKClientId = facilityClass.FKClientId,
|
||||||
|
StartTime = utcStart,
|
||||||
|
EndTime = utcStart.AddMinutes(facilityClass.DurationMinutes),
|
||||||
|
Capacity = facilityClass.Capacity,
|
||||||
|
IsUnlimitedCapacity = facilityClass.IsUnlimitedCapacity,
|
||||||
|
FKFacilityClassId = facilityClass.FacilityClassId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toAdd.Count > 0)
|
||||||
|
await _classRepository.AddOccurrencesAsync(toAdd);
|
||||||
|
|
||||||
|
facilityClass.OccurrencesGeneratedThrough = genTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime LocalToUtc(DateTime localUnspecified)
|
||||||
|
=> TimeZoneInfo.ConvertTimeToUtc(DateTime.SpecifyKind(localUnspecified, DateTimeKind.Unspecified), TimeZoneHelper.SriLanka);
|
||||||
|
|
||||||
|
private async Task AddCustomOccurrencesAsync(FacilityClass facilityClass, Dictionary<string, JsonElement> data, string key = "customOccurrences")
|
||||||
|
{
|
||||||
|
if (!data.ContainsKey(key) || data[key].ValueKind != JsonValueKind.Array) return;
|
||||||
|
|
||||||
|
var occurrences = new List<FacilitySession>();
|
||||||
|
foreach (var occEl in data[key].EnumerateArray())
|
||||||
|
{
|
||||||
|
var startTime = DateTime.Parse(occEl.GetProperty("startTime").GetString()!).ToUniversalTime();
|
||||||
|
var durationMinutes = occEl.GetProperty("durationMinutes").GetInt32();
|
||||||
|
if (durationMinutes < 1) throw new ArgumentException("durationMinutes must be at least 1");
|
||||||
|
|
||||||
|
occurrences.Add(new FacilitySession
|
||||||
|
{
|
||||||
|
FKFacilityId = facilityClass.FKFacilityId,
|
||||||
|
FKClientId = facilityClass.FKClientId,
|
||||||
|
StartTime = startTime,
|
||||||
|
EndTime = startTime.AddMinutes(durationMinutes),
|
||||||
|
Capacity = facilityClass.Capacity,
|
||||||
|
IsUnlimitedCapacity = facilityClass.IsUnlimitedCapacity,
|
||||||
|
FKFacilityClassId = facilityClass.FacilityClassId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (occurrences.Count > 0)
|
||||||
|
await _classRepository.AddOccurrencesAsync(occurrences);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FacilityClassDto MapToDto(FacilityClass fc) => new()
|
||||||
|
{
|
||||||
|
FacilityClassId = fc.FacilityClassId,
|
||||||
|
FacilityId = fc.FKFacilityId,
|
||||||
|
FacilityName = fc.Facility?.Name,
|
||||||
|
Name = fc.Name,
|
||||||
|
Description = fc.Description,
|
||||||
|
RecurrenceType = fc.RecurrenceType,
|
||||||
|
StartTimeOfDay = fc.RecurrenceType == "Custom" ? null : fc.StartTimeOfDay.ToString("hh\\:mm"),
|
||||||
|
DurationMinutes = fc.DurationMinutes,
|
||||||
|
WeekDaysMask = fc.WeekDaysMask,
|
||||||
|
MonthDays = fc.MonthDays,
|
||||||
|
SeriesStartDate = fc.SeriesStartDate,
|
||||||
|
SeriesEndDate = fc.SeriesEndDate,
|
||||||
|
Capacity = fc.Capacity,
|
||||||
|
IsUnlimitedCapacity = fc.IsUnlimitedCapacity,
|
||||||
|
IsActive = fc.IsActive,
|
||||||
|
CreatedAt = fc.CreatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ namespace Fitness_core_BE.Services.CustomerPlanService
|
|||||||
public class CustomerPlanService(
|
public class CustomerPlanService(
|
||||||
ICustomerPlanRepository customerPlanRepository,
|
ICustomerPlanRepository customerPlanRepository,
|
||||||
IFacilityRepository facilityRepository,
|
IFacilityRepository facilityRepository,
|
||||||
|
IFacilityClassRepository facilityClassRepository,
|
||||||
IUnitOfWork uow,
|
IUnitOfWork uow,
|
||||||
UserRoleHelper userRoleHelper) : ICustomerPlanService
|
UserRoleHelper userRoleHelper) : ICustomerPlanService
|
||||||
{
|
{
|
||||||
@@ -26,6 +27,7 @@ namespace Fitness_core_BE.Services.CustomerPlanService
|
|||||||
|
|
||||||
private readonly ICustomerPlanRepository _customerPlanRepository = customerPlanRepository;
|
private readonly ICustomerPlanRepository _customerPlanRepository = customerPlanRepository;
|
||||||
private readonly IFacilityRepository _facilityRepository = facilityRepository;
|
private readonly IFacilityRepository _facilityRepository = facilityRepository;
|
||||||
|
private readonly IFacilityClassRepository _facilityClassRepository = facilityClassRepository;
|
||||||
private readonly IUnitOfWork _uow = uow;
|
private readonly IUnitOfWork _uow = uow;
|
||||||
private readonly UserRoleHelper _userRoleHelper = userRoleHelper;
|
private readonly UserRoleHelper _userRoleHelper = userRoleHelper;
|
||||||
|
|
||||||
@@ -101,6 +103,16 @@ namespace Fitness_core_BE.Services.CustomerPlanService
|
|||||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||||
Guid clientId = await _userRoleHelper.GetClientId(userId);
|
Guid clientId = await _userRoleHelper.GetClientId(userId);
|
||||||
|
|
||||||
|
// Only meaningful when facilityClassId is set below (the Classes tab's "Manage
|
||||||
|
// Plan" action) -- never consulted for an ordinary plan, which always grants gym
|
||||||
|
// access regardless of this stored value. Defaults to false for a class plan
|
||||||
|
// (the "sportsman" case: class access only) unless staff opts it on.
|
||||||
|
Guid? facilityClassId = data.ContainsKey("facilityClassId") && data["facilityClassId"].ValueKind != JsonValueKind.Null
|
||||||
|
? Guid.Parse(data["facilityClassId"].GetString()!) : null;
|
||||||
|
var grantsGymAccess = facilityClassId.HasValue
|
||||||
|
? data.ContainsKey("grantsGymAccess") && data["grantsGymAccess"].GetBoolean()
|
||||||
|
: true;
|
||||||
|
|
||||||
var customerPlan = new Models.CustomerPlan
|
var customerPlan = new Models.CustomerPlan
|
||||||
{
|
{
|
||||||
FKClientId = clientId,
|
FKClientId = clientId,
|
||||||
@@ -124,7 +136,8 @@ namespace Fitness_core_BE.Services.CustomerPlanService
|
|||||||
: null,
|
: null,
|
||||||
SessionRolloverPolicy = data.ContainsKey("sessionRolloverPolicy") ? data["sessionRolloverPolicy"].GetString() ?? RolloverNone : RolloverNone,
|
SessionRolloverPolicy = data.ContainsKey("sessionRolloverPolicy") ? data["sessionRolloverPolicy"].GetString() ?? RolloverNone : RolloverNone,
|
||||||
RolloverGraceDays = data.ContainsKey("rolloverGraceDays") ? data["rolloverGraceDays"].GetInt32() : 0,
|
RolloverGraceDays = data.ContainsKey("rolloverGraceDays") ? data["rolloverGraceDays"].GetInt32() : 0,
|
||||||
BillingTiming = data.ContainsKey("billingTiming") ? data["billingTiming"].GetString() ?? BillingPrepaid : BillingPrepaid
|
BillingTiming = data.ContainsKey("billingTiming") ? data["billingTiming"].GetString() ?? BillingPrepaid : BillingPrepaid,
|
||||||
|
GrantsGymAccess = grantsGymAccess
|
||||||
};
|
};
|
||||||
|
|
||||||
NormalizeAndValidate(customerPlan);
|
NormalizeAndValidate(customerPlan);
|
||||||
@@ -132,6 +145,9 @@ namespace Fitness_core_BE.Services.CustomerPlanService
|
|||||||
var result = await _customerPlanRepository.CreateCustomerPlan(customerPlan);
|
var result = await _customerPlanRepository.CreateCustomerPlan(customerPlan);
|
||||||
await _uow.SaveChangesAsync();
|
await _uow.SaveChangesAsync();
|
||||||
|
|
||||||
|
if (facilityClassId.HasValue)
|
||||||
|
await _facilityClassRepository.LinkPlanToClassAsync(result.CustomerPlanId, facilityClassId.Value);
|
||||||
|
|
||||||
var placeIds = DataExtractor.GetOptionalGuidList(data, "placeIds");
|
var placeIds = DataExtractor.GetOptionalGuidList(data, "placeIds");
|
||||||
if (placeIds != null)
|
if (placeIds != null)
|
||||||
await _facilityRepository.SetPlanFacilitiesAsync(result.CustomerPlanId, placeIds);
|
await _facilityRepository.SetPlanFacilitiesAsync(result.CustomerPlanId, placeIds);
|
||||||
|
|||||||
+28
-11
@@ -440,13 +440,27 @@ namespace Fitness_core_BE.Services.CustomerService
|
|||||||
// member is created without a plan — e.g. a group secondary added later).
|
// member is created without a plan — e.g. a group secondary added later).
|
||||||
if (data.ContainsKey("customerPlanId") && data["customerPlanId"].ValueKind != JsonValueKind.Null)
|
if (data.ContainsKey("customerPlanId") && data["customerPlanId"].ValueKind != JsonValueKind.Null)
|
||||||
{
|
{
|
||||||
|
var customerPlanId = Guid.Parse(data["customerPlanId"].GetString()!);
|
||||||
|
|
||||||
|
// A class-linked plan (created from the Classes tab's "Manage Plan" action)
|
||||||
|
// stacks freely with other class-only plans and with one gym-access plan --
|
||||||
|
// only a second GYM-ACCESS subscription is blocked. See
|
||||||
|
// CustomerPlan.GrantsGymAccess.
|
||||||
|
var grantsGymAccess = await _customerSubscriptionRepository.PlanGrantsGymAccessAsync(customerPlanId);
|
||||||
|
|
||||||
// Guard against duplicate active subscriptions at this gym: callers like
|
// Guard against duplicate active subscriptions at this gym: callers like
|
||||||
// the create-existing-customer and edit-customer flows submit whatever plan
|
// the create-existing-customer and edit-customer flows submit whatever plan
|
||||||
// is currently shown on the form, not necessarily a deliberate "assign a new
|
// is currently shown on the form, not necessarily a deliberate "assign a new
|
||||||
// plan" action, so re-submitting an already-assigned plan must not stack a
|
// plan" action, so re-submitting an already-assigned plan must not stack a
|
||||||
// second active subscription. Assigning a genuinely different plan belongs to
|
// second active subscription. Assigning a genuinely different GYM plan
|
||||||
// ChangeMembershipPlan, which explicitly deactivates the old one first.
|
// belongs to ChangeMembershipPlan, which explicitly deactivates the old one
|
||||||
var existingActive = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(customerId);
|
// first; a genuinely different CLASS plan is just a new, additional
|
||||||
|
// subscription (handled below) -- it's only a duplicate of itself that's
|
||||||
|
// blocked here.
|
||||||
|
var existingActive = grantsGymAccess
|
||||||
|
? await _customerSubscriptionRepository.GetActiveGymAccessSubscriptionAsync(customerId)
|
||||||
|
: await _customerSubscriptionRepository.GetActiveSubscriptionForPlanAsync(customerId, customerPlanId);
|
||||||
|
|
||||||
if (existingActive != null)
|
if (existingActive != null)
|
||||||
{
|
{
|
||||||
alreadyAssigned = true;
|
alreadyAssigned = true;
|
||||||
@@ -456,16 +470,19 @@ namespace Fitness_core_BE.Services.CustomerService
|
|||||||
invoiceAmount = existingInvoice?.Amount;
|
invoiceAmount = existingInvoice?.Amount;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
{
|
||||||
|
if (grantsGymAccess)
|
||||||
{
|
{
|
||||||
// Not primary anywhere (checked above), but could still be an active
|
// Not primary anywhere (checked above), but could still be an active
|
||||||
// secondary on someone else's group plan -- GetActiveSubscriptionByCustomerId
|
// secondary on someone else's group plan -- GetActiveGymAccessSubscriptionAsync
|
||||||
// can't see that (it only matches CustomerSubscription.FKCustomerId, the
|
// can't see that (it only matches CustomerSubscription.FKCustomerId, the
|
||||||
// primary). One customer, one plan at a time either way.
|
// primary). One customer, one GYM-ACCESS plan at a time; class-only
|
||||||
var hasOtherActivePlan = await _customerSubscriptionRepository.HasAnyActiveSubscriptionAsync(customerId);
|
// plans are unrestricted.
|
||||||
if (hasOtherActivePlan)
|
var hasOtherActiveGymPlan = await _customerSubscriptionRepository.HasActiveGymAccessPlanAsync(customerId);
|
||||||
|
if (hasOtherActiveGymPlan)
|
||||||
throw new ArgumentException("This customer already has an active plan. Remove them from it before assigning a new one.");
|
throw new ArgumentException("This customer already has an active plan. Remove them from it before assigning a new one.");
|
||||||
|
}
|
||||||
|
|
||||||
var customerPlanId = Guid.Parse(data["customerPlanId"].GetString()!);
|
|
||||||
var onboardDate = DataExtractor.GetOptionalDateTime(data, "onboardDate");
|
var onboardDate = DataExtractor.GetOptionalDateTime(data, "onboardDate");
|
||||||
var chargePayment = DataExtractor.GetOptionalBool(data, "chargePayment") ?? true;
|
var chargePayment = DataExtractor.GetOptionalBool(data, "chargePayment") ?? true;
|
||||||
|
|
||||||
@@ -1403,7 +1420,7 @@ namespace Fitness_core_BE.Services.CustomerService
|
|||||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||||
Guid cusId = await _userRoleHelper.GetCusId(userId);
|
Guid cusId = await _userRoleHelper.GetCusId(userId);
|
||||||
|
|
||||||
var subscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(cusId);
|
var subscription = await _customerSubscriptionRepository.GetActiveGymAccessSubscriptionAsync(cusId);
|
||||||
if (subscription == null)
|
if (subscription == null)
|
||||||
return new { hasSubscription = false };
|
return new { hasSubscription = false };
|
||||||
|
|
||||||
@@ -1477,7 +1494,7 @@ namespace Fitness_core_BE.Services.CustomerService
|
|||||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||||
Guid cusId = await _userRoleHelper.GetCusId(userId);
|
Guid cusId = await _userRoleHelper.GetCusId(userId);
|
||||||
|
|
||||||
var customerSubscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(cusId);
|
var customerSubscription = await _customerSubscriptionRepository.GetActiveGymAccessSubscriptionAsync(cusId);
|
||||||
|
|
||||||
if (customerSubscription != null)
|
if (customerSubscription != null)
|
||||||
{
|
{
|
||||||
@@ -1539,7 +1556,7 @@ namespace Fitness_core_BE.Services.CustomerService
|
|||||||
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||||
Guid cusId = await _userRoleHelper.GetCusId(userId);
|
Guid cusId = await _userRoleHelper.GetCusId(userId);
|
||||||
|
|
||||||
var subscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(cusId);
|
var subscription = await _customerSubscriptionRepository.GetActiveGymAccessSubscriptionAsync(cusId);
|
||||||
if (subscription == null)
|
if (subscription == null)
|
||||||
throw new KeyNotFoundException("You don't have an active gym to unfollow.");
|
throw new KeyNotFoundException("You don't have an active gym to unfollow.");
|
||||||
|
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ namespace Fitness_core_BE.Services.CustomerSubscription
|
|||||||
}
|
}
|
||||||
|
|
||||||
var customerId = Guid.Parse(data["customerId"].GetString()!);
|
var customerId = Guid.Parse(data["customerId"].GetString()!);
|
||||||
var subscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(customerId);
|
var subscription = await _customerSubscriptionRepository.GetActiveGymAccessSubscriptionAsync(customerId);
|
||||||
|
|
||||||
if (subscription == null)
|
if (subscription == null)
|
||||||
{
|
{
|
||||||
@@ -257,11 +257,15 @@ namespace Fitness_core_BE.Services.CustomerSubscription
|
|||||||
if (alreadyMember != null)
|
if (alreadyMember != null)
|
||||||
throw new ArgumentException("This customer is already a member of the subscription");
|
throw new ArgumentException("This customer is already a member of the subscription");
|
||||||
|
|
||||||
// One customer, one plan at a time -- catches both "already primary on their own
|
// One customer, one GYM-ACCESS plan at a time -- catches both "already primary
|
||||||
// plan" and "already a secondary on a different group", neither of which the
|
// on their own plan" and "already a secondary on a different group", neither of
|
||||||
// check above sees (it only looks at this one subscription).
|
// which the check above sees (it only looks at this one subscription). Group
|
||||||
var hasOtherActivePlan = await _customerSubscriptionRepository.HasAnyActiveSubscriptionAsync(customerId);
|
// plans are ordinary gym plans (no class links), so this is always the
|
||||||
if (hasOtherActivePlan)
|
// gym-access check in practice; left as such rather than a generic
|
||||||
|
// HasAnyActiveSubscriptionAsync so a class-only subscription elsewhere never
|
||||||
|
// blocks joining a group.
|
||||||
|
var hasOtherActiveGymPlan = await _customerSubscriptionRepository.HasActiveGymAccessPlanAsync(customerId);
|
||||||
|
if (hasOtherActiveGymPlan)
|
||||||
throw new ArgumentException("This customer already has an active plan. Remove them from it before adding them to another.");
|
throw new ArgumentException("This customer already has an active plan. Remove them from it before adding them to another.");
|
||||||
|
|
||||||
var member = await _customerSubscriptionRepository.AddSubscriptionMemberAsync(new SubscriptionMember
|
var member = await _customerSubscriptionRepository.AddSubscriptionMemberAsync(new SubscriptionMember
|
||||||
|
|||||||
+91
-38
@@ -10,15 +10,22 @@ namespace Fitness_core_BE.Services
|
|||||||
{
|
{
|
||||||
public class FacilityService : IFacilityService
|
public class FacilityService : IFacilityService
|
||||||
{
|
{
|
||||||
private static readonly string[] ValidBookingModes = ["Scheduled", "AdHoc"];
|
|
||||||
|
|
||||||
private readonly IFacilityRepository _facilityRepository;
|
private readonly IFacilityRepository _facilityRepository;
|
||||||
|
private readonly IClassService _classService;
|
||||||
|
private readonly IBookingService _bookingService;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
private readonly UserRoleHelper _userRoleHelper;
|
private readonly UserRoleHelper _userRoleHelper;
|
||||||
|
|
||||||
public FacilityService(IFacilityRepository facilityRepository, IUnitOfWork uow, UserRoleHelper userRoleHelper)
|
public FacilityService(
|
||||||
|
IFacilityRepository facilityRepository,
|
||||||
|
IClassService classService,
|
||||||
|
IBookingService bookingService,
|
||||||
|
IUnitOfWork uow,
|
||||||
|
UserRoleHelper userRoleHelper)
|
||||||
{
|
{
|
||||||
_facilityRepository = facilityRepository;
|
_facilityRepository = facilityRepository;
|
||||||
|
_classService = classService;
|
||||||
|
_bookingService = bookingService;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
_userRoleHelper = userRoleHelper;
|
_userRoleHelper = userRoleHelper;
|
||||||
}
|
}
|
||||||
@@ -44,16 +51,17 @@ namespace Fitness_core_BE.Services
|
|||||||
var data = DeserializePayload.Deserialize(payload);
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
if (!data.ContainsKey("name")) throw new KeyNotFoundException("name is required");
|
if (!data.ContainsKey("name")) throw new KeyNotFoundException("name is required");
|
||||||
|
|
||||||
var bookingMode = data.ContainsKey("bookingMode") ? data["bookingMode"].GetString() ?? "Scheduled" : "Scheduled";
|
var supportsClasses = data.ContainsKey("supportsClasses") ? data["supportsClasses"].GetBoolean() : true;
|
||||||
if (!ValidBookingModes.Contains(bookingMode))
|
var supportsBooking = data.ContainsKey("supportsBooking") ? data["supportsBooking"].GetBoolean() : false;
|
||||||
throw new ArgumentException($"bookingMode must be one of: {string.Join(", ", ValidBookingModes)}");
|
if (!supportsClasses && !supportsBooking)
|
||||||
|
throw new ArgumentException("At least one of SupportsClasses/SupportsBooking must be true");
|
||||||
|
|
||||||
var defaultCapacity = data.ContainsKey("defaultCapacity") ? data["defaultCapacity"].GetInt32() : 1;
|
var defaultCapacity = data.ContainsKey("defaultCapacity") ? data["defaultCapacity"].GetInt32() : 1;
|
||||||
if (bookingMode == "Scheduled" && defaultCapacity < 1)
|
if (supportsClasses && defaultCapacity < 1)
|
||||||
throw new ArgumentException("defaultCapacity must be at least 1");
|
throw new ArgumentException("defaultCapacity must be at least 1");
|
||||||
|
|
||||||
var numberOfUnits = data.ContainsKey("numberOfUnits") ? data["numberOfUnits"].GetInt32() : 1;
|
var numberOfUnits = data.ContainsKey("numberOfUnits") ? data["numberOfUnits"].GetInt32() : 1;
|
||||||
if (bookingMode == "AdHoc" && numberOfUnits < 1)
|
if (supportsBooking && numberOfUnits < 1)
|
||||||
throw new ArgumentException("numberOfUnits must be at least 1");
|
throw new ArgumentException("numberOfUnits must be at least 1");
|
||||||
|
|
||||||
var pricePerHour = data.ContainsKey("pricePerHour") ? data["pricePerHour"].GetDecimal() : 0m;
|
var pricePerHour = data.ContainsKey("pricePerHour") ? data["pricePerHour"].GetDecimal() : 0m;
|
||||||
@@ -66,7 +74,8 @@ namespace Fitness_core_BE.Services
|
|||||||
{
|
{
|
||||||
FKClientId = clientId,
|
FKClientId = clientId,
|
||||||
Name = data["name"].GetString() ?? "",
|
Name = data["name"].GetString() ?? "",
|
||||||
BookingMode = bookingMode,
|
SupportsClasses = supportsClasses,
|
||||||
|
SupportsBooking = supportsBooking,
|
||||||
Description = data.ContainsKey("description") ? data["description"].GetString() : null,
|
Description = data.ContainsKey("description") ? data["description"].GetString() : null,
|
||||||
DefaultCapacity = defaultCapacity,
|
DefaultCapacity = defaultCapacity,
|
||||||
NumberOfUnits = numberOfUnits,
|
NumberOfUnits = numberOfUnits,
|
||||||
@@ -98,13 +107,16 @@ namespace Fitness_core_BE.Services
|
|||||||
?? throw new KeyNotFoundException("Facility not found");
|
?? throw new KeyNotFoundException("Facility not found");
|
||||||
|
|
||||||
if (data.ContainsKey("name")) existing.Name = data["name"].GetString() ?? existing.Name;
|
if (data.ContainsKey("name")) existing.Name = data["name"].GetString() ?? existing.Name;
|
||||||
if (data.ContainsKey("bookingMode"))
|
|
||||||
{
|
// Validate on the merged result, not each incoming field in isolation -- e.g.
|
||||||
var bookingMode = data["bookingMode"].GetString() ?? existing.BookingMode;
|
// flipping SupportsClasses off must still leave SupportsBooking (old or new) true.
|
||||||
if (!ValidBookingModes.Contains(bookingMode))
|
var newSupportsClasses = data.ContainsKey("supportsClasses") ? data["supportsClasses"].GetBoolean() : existing.SupportsClasses;
|
||||||
throw new ArgumentException($"bookingMode must be one of: {string.Join(", ", ValidBookingModes)}");
|
var newSupportsBooking = data.ContainsKey("supportsBooking") ? data["supportsBooking"].GetBoolean() : existing.SupportsBooking;
|
||||||
existing.BookingMode = bookingMode;
|
if (!newSupportsClasses && !newSupportsBooking)
|
||||||
}
|
throw new ArgumentException("At least one of SupportsClasses/SupportsBooking must be true");
|
||||||
|
existing.SupportsClasses = newSupportsClasses;
|
||||||
|
existing.SupportsBooking = newSupportsBooking;
|
||||||
|
|
||||||
if (data.ContainsKey("description")) existing.Description = data["description"].GetString();
|
if (data.ContainsKey("description")) existing.Description = data["description"].GetString();
|
||||||
if (data.ContainsKey("defaultCapacity"))
|
if (data.ContainsKey("defaultCapacity"))
|
||||||
{
|
{
|
||||||
@@ -179,8 +191,8 @@ namespace Fitness_core_BE.Services
|
|||||||
if (facility == null || facility.FKClientId != clientId)
|
if (facility == null || facility.FKClientId != clientId)
|
||||||
throw new KeyNotFoundException("Facility not found");
|
throw new KeyNotFoundException("Facility not found");
|
||||||
|
|
||||||
if (facility.BookingMode != "Scheduled")
|
if (!facility.SupportsClasses)
|
||||||
throw new ArgumentException("Sessions can only be created for Scheduled-mode facilities");
|
throw new ArgumentException("Sessions can only be created for facilities that support classes");
|
||||||
|
|
||||||
var startTime = DateTime.Parse(data["startTime"].GetString()!).ToUniversalTime();
|
var startTime = DateTime.Parse(data["startTime"].GetString()!).ToUniversalTime();
|
||||||
var endTime = DateTime.Parse(data["endTime"].GetString()!).ToUniversalTime();
|
var endTime = DateTime.Parse(data["endTime"].GetString()!).ToUniversalTime();
|
||||||
@@ -228,9 +240,15 @@ namespace Fitness_core_BE.Services
|
|||||||
var toUtc = data.ContainsKey("toDate") && data["toDate"].ValueKind != JsonValueKind.Null
|
var toUtc = data.ContainsKey("toDate") && data["toDate"].ValueKind != JsonValueKind.Null
|
||||||
? DateTime.Parse(data["toDate"].GetString()!).ToUniversalTime()
|
? DateTime.Parse(data["toDate"].GetString()!).ToUniversalTime()
|
||||||
: fromUtc.AddDays(7);
|
: fromUtc.AddDays(7);
|
||||||
|
bool includeCancelled = data.ContainsKey("includeCancelled") && data["includeCancelled"].GetBoolean();
|
||||||
|
|
||||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
var sessions = await _facilityRepository.GetSessions(clientId, facilityId, fromUtc, toUtc);
|
|
||||||
|
// The one read path everything (calendar, class-details occurrence list) already
|
||||||
|
// goes through -- lazy generation here means no background job is needed.
|
||||||
|
await _classService.EnsureOccurrencesGeneratedAsync(clientId);
|
||||||
|
|
||||||
|
var sessions = await _facilityRepository.GetSessions(clientId, facilityId, fromUtc, toUtc, includeCancelled);
|
||||||
|
|
||||||
var result = new List<FacilitySessionDto>();
|
var result = new List<FacilitySessionDto>();
|
||||||
foreach (var session in sessions)
|
foreach (var session in sessions)
|
||||||
@@ -242,6 +260,13 @@ namespace Fitness_core_BE.Services
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>"Mark not held" -- sets IsCancelled/NotHeldReason/NotHeldAt and cascades to
|
||||||
|
/// every Confirmed booking on the occurrence: cancels it and refunds any subscription
|
||||||
|
/// allowance, through the exact same core BookingService.CancelBooking uses (one refund
|
||||||
|
/// code path, not two). The freed time becomes bookable again automatically -- the
|
||||||
|
/// walk-in overlap guard only checks non-cancelled sessions. Same entry point as the old
|
||||||
|
/// bare-IsCancelled CancelFacilitySession this replaces; also reachable via the
|
||||||
|
/// memberships-gated facility-classes/.../not-held route for the Classes tab.</summary>
|
||||||
public async Task<object> CancelFacilitySession(object payload, HttpContext httpContext)
|
public async Task<object> CancelFacilitySession(object payload, HttpContext httpContext)
|
||||||
{
|
{
|
||||||
await _uow.BeginAsync();
|
await _uow.BeginAsync();
|
||||||
@@ -249,11 +274,16 @@ namespace Fitness_core_BE.Services
|
|||||||
{
|
{
|
||||||
var data = DeserializePayload.Deserialize(payload);
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
var facilitySessionId = Guid.Parse(data["facilitySessionId"].GetString()!);
|
var facilitySessionId = Guid.Parse(data["facilitySessionId"].GetString()!);
|
||||||
|
var reason = data.ContainsKey("reason") && data["reason"].ValueKind != JsonValueKind.Null
|
||||||
|
? data["reason"].GetString() : null;
|
||||||
|
|
||||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
var session = await _facilityRepository.CancelFacilitySession(facilitySessionId, clientId)
|
var session = await _facilityRepository.MarkFacilitySessionNotHeldAsync(facilitySessionId, clientId, reason)
|
||||||
?? throw new KeyNotFoundException("Facility session not found");
|
?? throw new KeyNotFoundException("Facility session not found");
|
||||||
|
|
||||||
|
foreach (var booking in session.Bookings.Where(b => b.Status == "Confirmed"))
|
||||||
|
await _bookingService.CancelBookingCore(booking);
|
||||||
|
|
||||||
await _uow.CommitAsync();
|
await _uow.CommitAsync();
|
||||||
return MapToDto(session);
|
return MapToDto(session);
|
||||||
}
|
}
|
||||||
@@ -335,39 +365,56 @@ namespace Fitness_core_BE.Services
|
|||||||
var result = new List<FacilityAvailabilityDto>();
|
var result = new List<FacilityAvailabilityDto>();
|
||||||
foreach (var facility in facilities)
|
foreach (var facility in facilities)
|
||||||
{
|
{
|
||||||
if (facility.BookingMode == "AdHoc")
|
// True interval overlap, not "session starts inside the window" -- a class that
|
||||||
|
// started earlier and is still running must still count.
|
||||||
|
var overlappingSessions = await _facilityRepository.GetOverlappingActiveSessionsAsync(facility.FacilityId, startTime, endTime);
|
||||||
|
|
||||||
|
int? bookingTotal = null, bookingFree = null, classTotal = null, classFree = null;
|
||||||
|
bool classIsUnlimited = false;
|
||||||
|
|
||||||
|
if (facility.SupportsBooking)
|
||||||
{
|
{
|
||||||
var overlapping = await _facilityRepository.GetOverlappingAdhocBookingCountAsync(facility.FacilityId, startTime, endTime);
|
var overlapping = await _facilityRepository.GetOverlappingWalkInBookingCountAsync(facility.FacilityId, startTime, endTime);
|
||||||
result.Add(new FacilityAvailabilityDto
|
bookingTotal = facility.NumberOfUnits;
|
||||||
|
// A running class occupies the whole facility, not just a seat.
|
||||||
|
bookingFree = overlappingSessions.Count > 0 ? 0 : Math.Max(0, facility.NumberOfUnits - overlapping);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (facility.SupportsClasses)
|
||||||
{
|
{
|
||||||
FacilityId = facility.FacilityId,
|
// An unlimited-capacity occurrence has no meaningful total -- Capacity stays
|
||||||
FacilityName = facility.Name,
|
// null on it either way, so summing would silently under-count by falling
|
||||||
BookingMode = facility.BookingMode,
|
// back to the facility default instead of reporting "no cap" at all.
|
||||||
TotalUnits = facility.NumberOfUnits,
|
if (overlappingSessions.Any(s => s.IsUnlimitedCapacity))
|
||||||
UnitsFree = Math.Max(0, facility.NumberOfUnits - overlapping),
|
{
|
||||||
PricePerHour = facility.PricePerHour
|
classIsUnlimited = true;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var sessions = await _facilityRepository.GetSessions(clientId, facility.FacilityId, startTime, endTime);
|
|
||||||
var overlappingSessions = sessions.Where(s => s.StartTime < endTime && s.EndTime > startTime).ToList();
|
|
||||||
var totalCapacity = overlappingSessions.Sum(s => s.Capacity ?? facility.DefaultCapacity);
|
var totalCapacity = overlappingSessions.Sum(s => s.Capacity ?? facility.DefaultCapacity);
|
||||||
var totalBooked = 0;
|
var totalBooked = 0;
|
||||||
foreach (var s in overlappingSessions)
|
foreach (var s in overlappingSessions)
|
||||||
totalBooked += await _facilityRepository.GetActiveBookingCount(s.FacilitySessionId);
|
totalBooked += await _facilityRepository.GetActiveBookingCount(s.FacilitySessionId);
|
||||||
|
|
||||||
|
classTotal = totalCapacity;
|
||||||
|
classFree = Math.Max(0, totalCapacity - totalBooked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
result.Add(new FacilityAvailabilityDto
|
result.Add(new FacilityAvailabilityDto
|
||||||
{
|
{
|
||||||
FacilityId = facility.FacilityId,
|
FacilityId = facility.FacilityId,
|
||||||
FacilityName = facility.Name,
|
FacilityName = facility.Name,
|
||||||
BookingMode = facility.BookingMode,
|
SupportsClasses = facility.SupportsClasses,
|
||||||
TotalUnits = totalCapacity,
|
SupportsBooking = facility.SupportsBooking,
|
||||||
UnitsFree = Math.Max(0, totalCapacity - totalBooked),
|
BookingTotalUnits = bookingTotal,
|
||||||
|
BookingUnitsFree = bookingFree,
|
||||||
|
ClassTotalCapacity = classTotal,
|
||||||
|
ClassUnitsFree = classFree,
|
||||||
|
ClassIsUnlimitedCapacity = classIsUnlimited,
|
||||||
PricePerHour = facility.PricePerHour
|
PricePerHour = facility.PricePerHour
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -376,7 +423,8 @@ namespace Fitness_core_BE.Services
|
|||||||
{
|
{
|
||||||
FacilityId = facility.FacilityId,
|
FacilityId = facility.FacilityId,
|
||||||
Name = facility.Name,
|
Name = facility.Name,
|
||||||
BookingMode = facility.BookingMode,
|
SupportsClasses = facility.SupportsClasses,
|
||||||
|
SupportsBooking = facility.SupportsBooking,
|
||||||
Description = facility.Description,
|
Description = facility.Description,
|
||||||
DefaultCapacity = facility.DefaultCapacity,
|
DefaultCapacity = facility.DefaultCapacity,
|
||||||
NumberOfUnits = facility.NumberOfUnits,
|
NumberOfUnits = facility.NumberOfUnits,
|
||||||
@@ -395,8 +443,13 @@ namespace Fitness_core_BE.Services
|
|||||||
StartTime = session.StartTime,
|
StartTime = session.StartTime,
|
||||||
EndTime = session.EndTime,
|
EndTime = session.EndTime,
|
||||||
Capacity = session.Capacity ?? session.Facility?.DefaultCapacity ?? 1,
|
Capacity = session.Capacity ?? session.Facility?.DefaultCapacity ?? 1,
|
||||||
|
IsUnlimitedCapacity = session.IsUnlimitedCapacity,
|
||||||
BookedCount = bookedCount,
|
BookedCount = bookedCount,
|
||||||
IsCancelled = session.IsCancelled
|
IsCancelled = session.IsCancelled,
|
||||||
|
FacilityClassId = session.FKFacilityClassId,
|
||||||
|
FacilityClassName = session.FacilityClass?.Name,
|
||||||
|
NotHeldReason = session.NotHeldReason,
|
||||||
|
NotHeldAt = session.NotHeldAt
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ namespace Fitness_core_BE.Services
|
|||||||
// SubscriptionRenewalRunner still use DateTime.UtcNow, unaffected by this -- only the
|
// SubscriptionRenewalRunner still use DateTime.UtcNow, unaffected by this -- only the
|
||||||
// wake-up point shifts.
|
// wake-up point shifts.
|
||||||
private static readonly TimeSpan RunAtLocal = TimeSpan.FromHours(1);
|
private static readonly TimeSpan RunAtLocal = TimeSpan.FromHours(1);
|
||||||
private static readonly TimeZoneInfo SriLankaTimeZone = ResolveSriLankaTimeZone();
|
private static readonly TimeZoneInfo SriLankaTimeZone = Helpers.TimeZoneHelper.SriLanka;
|
||||||
|
|
||||||
private readonly IServiceScopeFactory _scopeFactory;
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
private readonly ILogger<SubscriptionRenewalJob> _logger;
|
private readonly ILogger<SubscriptionRenewalJob> _logger;
|
||||||
@@ -68,29 +68,6 @@ namespace Fitness_core_BE.Services
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static TimeZoneInfo ResolveSriLankaTimeZone()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return TimeZoneInfo.FindSystemTimeZoneById("Sri Lanka Standard Time"); // Windows ID
|
|
||||||
}
|
|
||||||
catch (TimeZoneNotFoundException)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return TimeZoneInfo.FindSystemTimeZoneById("Asia/Colombo"); // IANA ID (Linux/macOS)
|
|
||||||
}
|
|
||||||
catch (TimeZoneNotFoundException)
|
|
||||||
{
|
|
||||||
// Neither ID resolved (e.g. no tzdata on the host) -- Sri Lanka has kept a
|
|
||||||
// fixed UTC+5:30 offset with no DST since 2006, so a custom fixed-offset
|
|
||||||
// zone is a safe fallback rather than failing the whole job.
|
|
||||||
return TimeZoneInfo.CreateCustomTimeZone(
|
|
||||||
"Sri Lanka (fixed +05:30)", TimeSpan.FromMinutes(330), "Sri Lanka Time", "Sri Lanka Time");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delay-then-run, not run-then-delay: a fixed daily time trigger shouldn't fire an extra
|
// Delay-then-run, not run-then-delay: a fixed daily time trigger shouldn't fire an extra
|
||||||
// run just because the app happened to restart. Internal + static so it's unit-testable
|
// run just because the app happened to restart. Internal + static so it's unit-testable
|
||||||
// without spinning up the whole hosted service.
|
// without spinning up the whole hosted service.
|
||||||
|
|||||||
+32
-5
@@ -73,6 +73,11 @@ namespace Fitness_core_BE
|
|||||||
public DbSet<TimeBankCardUsageLog> TimeBankCardUsageLogs => Set<TimeBankCardUsageLog>();
|
public DbSet<TimeBankCardUsageLog> TimeBankCardUsageLogs => Set<TimeBankCardUsageLog>();
|
||||||
public DbSet<CustomerPlanFacility> CustomerPlanFacilities => Set<CustomerPlanFacility>();
|
public DbSet<CustomerPlanFacility> CustomerPlanFacilities => Set<CustomerPlanFacility>();
|
||||||
|
|
||||||
|
// Recurring class definitions + their generated FacilitySession occurrences (see
|
||||||
|
// Models/FacilityClass.cs), and which CustomerPlan(s) are sold against a class.
|
||||||
|
public DbSet<FacilityClass> FacilityClasses => Set<FacilityClass>();
|
||||||
|
public DbSet<CustomerPlanClass> CustomerPlanClasses => Set<CustomerPlanClass>();
|
||||||
|
|
||||||
// Booking-scoped RFID tag inventory (assigned to a Booking at check-in, released at
|
// Booking-scoped RFID tag inventory (assigned to a Booking at check-in, released at
|
||||||
// Complete/no-show sweep) -- see Models/RfidTag.cs. Not to be confused with
|
// Complete/no-show sweep) -- see Models/RfidTag.cs. Not to be confused with
|
||||||
// TimeBankCard's own, unrelated RfidTagId string field.
|
// TimeBankCard's own, unrelated RfidTagId string field.
|
||||||
@@ -197,6 +202,7 @@ namespace Fitness_core_BE
|
|||||||
// doesn't, since Client lives only in CentralDbContext).
|
// doesn't, since Client lives only in CentralDbContext).
|
||||||
modelBuilder.Entity<Facility>().Ignore(f => f.Client);
|
modelBuilder.Entity<Facility>().Ignore(f => f.Client);
|
||||||
modelBuilder.Entity<FacilitySession>().Ignore(s => s.Client);
|
modelBuilder.Entity<FacilitySession>().Ignore(s => s.Client);
|
||||||
|
modelBuilder.Entity<FacilityClass>().Ignore(fc => fc.Client);
|
||||||
modelBuilder.Entity<Booking>().Ignore(b => b.Client);
|
modelBuilder.Entity<Booking>().Ignore(b => b.Client);
|
||||||
modelBuilder.Entity<BookingInvoice>().Ignore(i => i.Client);
|
modelBuilder.Entity<BookingInvoice>().Ignore(i => i.Client);
|
||||||
modelBuilder.Entity<TimeBankCard>().Ignore(t => t.Client);
|
modelBuilder.Entity<TimeBankCard>().Ignore(t => t.Client);
|
||||||
@@ -210,10 +216,10 @@ namespace Fitness_core_BE
|
|||||||
// A session's capacity is enforced by counting non-cancelled bookings against it at
|
// A session's capacity is enforced by counting non-cancelled bookings against it at
|
||||||
// booking time, not by a unique index -- multiple guests/members can share one slot
|
// booking time, not by a unique index -- multiple guests/members can share one slot
|
||||||
// up to Facility.DefaultCapacity. What must stay unique is a member double-booking
|
// up to Facility.DefaultCapacity. What must stay unique is a member double-booking
|
||||||
// the same session twice. FKFacilitySessionId is null for every AdHoc booking, and
|
// the same session twice. FKFacilitySessionId is null for every WalkIn booking, and
|
||||||
// MySQL/InnoDB never treats two NULLs as duplicates in a unique index, so this index
|
// MySQL/InnoDB never treats two NULLs as duplicates in a unique index, so this index
|
||||||
// is naturally a no-op for AdHoc bookings -- their own overlap-vs-NumberOfUnits check
|
// is naturally a no-op for WalkIn bookings -- their own overlap-vs-NumberOfUnits check
|
||||||
// lives in BookingRepository.GetOverlappingAdhocBookingCountAsync instead.
|
// lives in FacilityRepository.GetOverlappingWalkInBookingCountAsync instead.
|
||||||
modelBuilder.Entity<Booking>()
|
modelBuilder.Entity<Booking>()
|
||||||
.HasIndex(b => new { b.FKFacilitySessionId, b.FKCustomerId })
|
.HasIndex(b => new { b.FKFacilitySessionId, b.FKCustomerId })
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
@@ -222,6 +228,17 @@ namespace Fitness_core_BE
|
|||||||
modelBuilder.Entity<FacilitySession>()
|
modelBuilder.Entity<FacilitySession>()
|
||||||
.HasIndex(s => new { s.FKFacilityId, s.StartTime });
|
.HasIndex(s => new { s.FKFacilityId, s.StartTime });
|
||||||
|
|
||||||
|
modelBuilder.Entity<FacilityClass>()
|
||||||
|
.HasIndex(fc => fc.FKFacilityId);
|
||||||
|
|
||||||
|
// Guards against double-generating the same occurrence when two concurrent calendar
|
||||||
|
// loads both trigger EnsureOccurrencesGeneratedAsync for the same class -- a dedup
|
||||||
|
// check in code alone isn't race-safe. MySQL unique indexes ignore NULL, so every
|
||||||
|
// pre-existing, non-class session (FKFacilityClassId null) is unaffected.
|
||||||
|
modelBuilder.Entity<FacilitySession>()
|
||||||
|
.HasIndex(s => new { s.FKFacilityClassId, s.StartTime })
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
modelBuilder.Entity<TimeBankCard>()
|
modelBuilder.Entity<TimeBankCard>()
|
||||||
.HasIndex(t => t.RfidTagId)
|
.HasIndex(t => t.RfidTagId)
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
@@ -283,15 +300,25 @@ namespace Fitness_core_BE
|
|||||||
.HasOne(pf => pf.Facility).WithMany()
|
.HasOne(pf => pf.Facility).WithMany()
|
||||||
.HasForeignKey(pf => pf.FKFacilityId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(pf => pf.FKFacilityId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<CustomerPlanClass>()
|
||||||
|
.HasIndex(pc => new { pc.FKCustomerPlanId, pc.FKFacilityClassId })
|
||||||
|
.IsUnique();
|
||||||
|
modelBuilder.Entity<CustomerPlanClass>()
|
||||||
|
.HasOne(pc => pc.CustomerPlan).WithMany()
|
||||||
|
.HasForeignKey(pc => pc.FKCustomerPlanId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
modelBuilder.Entity<CustomerPlanClass>()
|
||||||
|
.HasOne(pc => pc.FacilityClass).WithMany()
|
||||||
|
.HasForeignKey(pc => pc.FKFacilityClassId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
// RFID tag inventory is per-client; a tag code only needs to be unique within one
|
// RFID tag inventory is per-client; a tag code only needs to be unique within one
|
||||||
// club's pool.
|
// club's pool.
|
||||||
modelBuilder.Entity<RfidTag>()
|
modelBuilder.Entity<RfidTag>()
|
||||||
.HasIndex(t => new { t.FKClientId, t.TagCode })
|
.HasIndex(t => new { t.FKClientId, t.TagCode })
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
// AdHoc bookings share a facility's NumberOfUnits, checked by counting overlapping
|
// WalkIn bookings share a facility's NumberOfUnits, checked by counting overlapping
|
||||||
// non-cancelled/non-completed bookings at creation/reschedule time (see
|
// non-cancelled/non-completed bookings at creation/reschedule time (see
|
||||||
// BookingRepository.GetOverlappingAdhocBookingCountAsync) rather than a DB
|
// FacilityRepository.GetOverlappingWalkInBookingCountAsync) rather than a DB
|
||||||
// constraint -- an exclusion constraint over a time range isn't expressible as a
|
// constraint -- an exclusion constraint over a time range isn't expressible as a
|
||||||
// simple unique index, and MySQL/Pomelo has no range-overlap index type. This index
|
// simple unique index, and MySQL/Pomelo has no range-overlap index type. This index
|
||||||
// just makes that overlap query efficient.
|
// just makes that overlap query efficient.
|
||||||
|
|||||||
Reference in New Issue
Block a user