diff --git a/Controllers/BookingController.cs b/Controllers/BookingController.cs index 4b07266..8657f97 100644 --- a/Controllers/BookingController.cs +++ b/Controllers/BookingController.cs @@ -34,9 +34,9 @@ namespace Fitness_core_BE.Controllers [CustomMiddleware] [TenantResolution] - [HttpPost("api/app/bookings/adhoc")] - public async Task>> CreateMemberAdhocBooking([FromBody] CreateAdhocBookingRequest request) - => Response(await _bookingService.CreateMemberAdhocBooking(new + [HttpPost("api/app/bookings/walkin")] + public async Task>> CreateMemberWalkInBooking([FromBody] CreateWalkInBookingRequest request) + => Response(await _bookingService.CreateMemberWalkInBooking(new { facilityId = request.FacilityId, startTime = request.StartTime.ToString("o"), @@ -70,9 +70,9 @@ namespace Fitness_core_BE.Controllers }, HttpContext)); [AllowAnonymous] - [HttpPost("api/public/bookings/guest/adhoc")] - public async Task>> CreateGuestAdhocBooking([FromBody] CreateGuestAdhocBookingRequest request) - => Response(await _bookingService.CreateGuestAdhocBooking(new + [HttpPost("api/public/bookings/guest/walkin")] + public async Task>> CreateGuestWalkInBooking([FromBody] CreateGuestWalkInBookingRequest request) + => Response(await _bookingService.CreateGuestWalkInBooking(new { facilityId = request.FacilityId, startTime = request.StartTime.ToString("o"), diff --git a/Controllers/ClientController.cs b/Controllers/ClientController.cs index c56942c..05dabc0 100644 --- a/Controllers/ClientController.cs +++ b/Controllers/ClientController.cs @@ -639,6 +639,8 @@ namespace Fitness_core_BE.Controllers if (request.RolloverGraceDays.HasValue) payload["rolloverGraceDays"] = request.RolloverGraceDays.Value; if (request.BillingTiming != null) payload["billingTiming"] = request.BillingTiming; if (request.PlaceIds != null) payload["placeIds"] = request.PlaceIds; + if (request.FacilityClassId.HasValue) payload["facilityClassId"] = request.FacilityClassId.Value; + if (request.GrantsGymAccess.HasValue) payload["grantsGymAccess"] = request.GrantsGymAccess.Value; return Response(await _customerPlanService.CreateCustomerPlan(payload, HttpContext)); } diff --git a/Controllers/FacilityClassController.cs b/Controllers/FacilityClassController.cs new file mode 100644 index 0000000..7da7cca --- /dev/null +++ b/Controllers/FacilityClassController.cs @@ -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>> GetFacilityClasses([FromQuery] bool includeInactive = false) + => Response(await _classService.GetFacilityClasses(new { includeInactive }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("memberships")] + [HttpPost("")] + public async Task>> 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>> UpdateFacilityClass(Guid id, [FromBody] UpdateFacilityClassRequest request) + { + var payload = new Dictionary { ["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>> 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>> 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>> GetSchedulingConflicts() + => Response(await _classService.GetSchedulingConflicts(new { }, HttpContext)); + } +} diff --git a/Controllers/FacilityController.cs b/Controllers/FacilityController.cs index 96d2493..beb7ea3 100644 --- a/Controllers/FacilityController.cs +++ b/Controllers/FacilityController.cs @@ -33,7 +33,8 @@ namespace Fitness_core_BE.Controllers => Response(await _facilityService.CreateFacility(new { name = request.Name, - bookingMode = request.BookingMode, + supportsClasses = request.SupportsClasses, + supportsBooking = request.SupportsBooking, description = request.Description, defaultCapacity = request.DefaultCapacity, numberOfUnits = request.NumberOfUnits, @@ -48,7 +49,8 @@ namespace Fitness_core_BE.Controllers { var payload = new Dictionary { ["facilityId"] = id }; 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.DefaultCapacity.HasValue) payload["defaultCapacity"] = request.DefaultCapacity.Value; if (request.NumberOfUnits.HasValue) payload["numberOfUnits"] = request.NumberOfUnits.Value; @@ -90,20 +92,21 @@ namespace Fitness_core_BE.Controllers [RequiresPermission("bookings")] [HttpGet("sessions")] public async Task>> 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 { facilityId, fromDate = fromDate?.ToString("o"), - toDate = toDate?.ToString("o") + toDate = toDate?.ToString("o"), + includeCancelled }, HttpContext)); [CustomMiddleware] [TenantResolution] [RequiresPermission("bookings")] [HttpDelete("sessions/{id:guid}")] - public async Task>> CancelFacilitySession(Guid id) - => Response(await _facilityService.CancelFacilitySession(new { facilitySessionId = id }, HttpContext)); + public async Task>> CancelFacilitySession(Guid id, [FromBody] MarkNotHeldRequest? request) + => Response(await _facilityService.CancelFacilitySession(new { facilitySessionId = id, reason = request?.Reason }, HttpContext)); [CustomMiddleware] [TenantResolution] diff --git a/DTOs/Booking/BookingDto.cs b/DTOs/Booking/BookingDto.cs index 1d8e7da..9ea31b3 100644 --- a/DTOs/Booking/BookingDto.cs +++ b/DTOs/Booking/BookingDto.cs @@ -4,20 +4,20 @@ namespace Fitness_core_BE.DTOs.Booking { public Guid BookingId { get; set; } - /// Null for an AdHoc booking (no pre-created session). + /// Null for a WalkIn booking (no pre-created session). public Guid? FacilitySessionId { get; set; } public Guid FacilityId { get; set; } public string? FacilityName { get; set; } - /// "Scheduled" | "AdHoc" -- read off the facility, tells the FE which action set - /// applies (e.g. AdHoc bookings can be rescheduled to any free time; Scheduled ones only - /// to another session). + /// "Scheduled" | "WalkIn" -- tells the FE which action set applies (e.g. WalkIn + /// bookings can be rescheduled to any free time; Scheduled ones only to another + /// session). public string BookingMode { get; set; } = string.Empty; public DateTime StartTime { get; set; } public DateTime EndTime { get; set; } - /// Informational headcount, AdHoc only. + /// Informational headcount, WalkIn only. public int? PartySize { get; set; } public bool IsGuest { get; set; } diff --git a/DTOs/Booking/BookingRequests.cs b/DTOs/Booking/BookingRequests.cs index c52a523..31bfd0f 100644 --- a/DTOs/Booking/BookingRequests.cs +++ b/DTOs/Booking/BookingRequests.cs @@ -25,7 +25,7 @@ namespace Fitness_core_BE.DTOs.Booking public Guid? CustomerId { get; set; } } - public class CreateAdhocBookingRequest + public class CreateWalkInBookingRequest { [Required] public Guid FacilityId { get; set; } @@ -39,7 +39,7 @@ namespace Fitness_core_BE.DTOs.Booking public int? PartySize { get; set; } } - public class CreateGuestAdhocBookingRequest + public class CreateGuestWalkInBookingRequest { [Required] public Guid FacilityId { get; set; } @@ -98,7 +98,7 @@ namespace Fitness_core_BE.DTOs.Booking public string OtpCode { get; set; } = string.Empty; /// 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. public Guid? RfidTagId { get; set; } } @@ -115,14 +115,14 @@ namespace Fitness_core_BE.DTOs.Booking public string? GuestPhone { get; set; } } - /// Exactly one shape applies, matching the booking's current BookingMode: AdHoc + /// 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 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 /// availability check as creation, excluding the booking's own current slot. public class RescheduleBookingRequest { - // AdHoc reschedule. + // WalkIn reschedule. public DateTime? StartTime { get; set; } [Range(1, int.MaxValue, ErrorMessage = "DurationMinutes must be at least 1")] public int? DurationMinutes { get; set; } diff --git a/DTOs/CustomerPlan/CreateCustomerPlanRequest.cs b/DTOs/CustomerPlan/CreateCustomerPlanRequest.cs index b608af1..ccee145 100644 --- a/DTOs/CustomerPlan/CreateCustomerPlanRequest.cs +++ b/DTOs/CustomerPlan/CreateCustomerPlanRequest.cs @@ -58,5 +58,14 @@ namespace Fitness_core_BE.DTOs.CustomerPlan /// Places this plan grants access to. Null/omitted or empty = unrestricted /// (reaches every active access device, today's behavior). public List? PlaceIds { get; set; } + + /// 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. + public Guid? FacilityClassId { get; set; } + + /// 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. + public bool? GrantsGymAccess { get; set; } } } diff --git a/DTOs/Facility/FacilityClassDtos.cs b/DTOs/Facility/FacilityClassDtos.cs new file mode 100644 index 0000000..4572925 --- /dev/null +++ b/DTOs/Facility/FacilityClassDtos.cs @@ -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; } + /// "Daily" | "Weekly" | "Monthly" | "Custom". + public string RecurrenceType { get; set; } = string.Empty; + /// "HH:mm" local time -- null for Custom. + 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; } + } + + /// 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. + 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; } + } +} diff --git a/DTOs/Facility/FacilityClassRequests.cs b/DTOs/Facility/FacilityClassRequests.cs new file mode 100644 index 0000000..c7cf8d7 --- /dev/null +++ b/DTOs/Facility/FacilityClassRequests.cs @@ -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; } + + /// "Daily" | "Weekly" | "Monthly" | "Custom". + [Required] + public string RecurrenceType { get; set; } = "Weekly"; + + /// "HH:mm" local time -- required unless RecurrenceType == "Custom". + public string? StartTimeOfDay { get; set; } + + [Range(1, int.MaxValue, ErrorMessage = "DurationMinutes must be at least 1")] + public int DurationMinutes { get; set; } = 60; + + /// Weekly only. Bit flags, Sunday = 1 through Saturday = 64. + public int WeekDaysMask { get; set; } + + /// Monthly only. Comma-separated day-of-month list, e.g. "1,15". + 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; } + + /// RecurrenceType == "Custom" only -- explicit occurrence windows. + public List? 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; } + + /// RecurrenceType == "Custom" only -- adds more explicit occurrences without + /// touching existing ones (Custom has no rule to regenerate from). + public List? AddCustomOccurrences { get; set; } + } + + public class MarkNotHeldRequest + { + public string? Reason { get; set; } + } +} diff --git a/DTOs/Facility/FacilityDtos.cs b/DTOs/Facility/FacilityDtos.cs index 803d268..f3769f3 100644 --- a/DTOs/Facility/FacilityDtos.cs +++ b/DTOs/Facility/FacilityDtos.cs @@ -5,8 +5,8 @@ namespace Fitness_core_BE.DTOs.Facility public Guid FacilityId { get; set; } public string Name { get; set; } = string.Empty; - /// "Scheduled" | "AdHoc". - public string BookingMode { get; set; } = "Scheduled"; + public bool SupportsClasses { get; set; } + public bool SupportsBooking { get; set; } public string? Description { get; set; } public int DefaultCapacity { get; set; } public int NumberOfUnits { get; set; } @@ -27,20 +27,35 @@ namespace Fitness_core_BE.DTOs.Facility public DateTime StartTime { get; set; } public DateTime EndTime { get; set; } public int Capacity { get; set; } + /// When true, Capacity above is meaningless -- this occurrence has no cap. + public bool IsUnlimitedCapacity { get; set; } public int BookedCount { get; set; } public bool IsCancelled { get; set; } + /// Null for a manually-created session; set for a class occurrence. + public Guid? FacilityClassId { get; set; } + public string? FacilityClassName { get; set; } + public string? NotHeldReason { get; set; } + public DateTime? NotHeldAt { get; set; } } /// GET api/client/facilities/availability -- how much room is left for a requested - /// window, uniformly across both booking modes (TotalUnits/UnitsFree mean NumberOfUnits/ - /// overlap-count for AdHoc, DefaultCapacity/free-session-spots for Scheduled). + /// window. A facility can support both booking and classes at once, so the two number pairs + /// 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. public class FacilityAvailabilityDto { public Guid FacilityId { get; set; } public string FacilityName { get; set; } = string.Empty; - public string BookingMode { get; set; } = string.Empty; - public int TotalUnits { get; set; } - public int UnitsFree { get; set; } + public bool SupportsClasses { get; set; } + public bool SupportsBooking { get; set; } + public int? BookingTotalUnits { get; set; } + public int? BookingUnitsFree { get; set; } + public int? ClassTotalCapacity { get; set; } + public int? ClassUnitsFree { get; set; } + /// True when an overlapping class occurrence has no headcount cap -- when set, + /// ClassTotalCapacity/ClassUnitsFree stay null (unbounded, not zero). + public bool ClassIsUnlimitedCapacity { get; set; } public decimal PricePerHour { get; set; } } } diff --git a/DTOs/Facility/FacilityRequests.cs b/DTOs/Facility/FacilityRequests.cs index f0dc76b..6ccc67d 100644 --- a/DTOs/Facility/FacilityRequests.cs +++ b/DTOs/Facility/FacilityRequests.cs @@ -7,17 +7,17 @@ namespace Fitness_core_BE.DTOs.Facility [Required] public string Name { get; set; } = string.Empty; - /// "Scheduled" | "AdHoc". - [Required] - public string BookingMode { get; set; } = "Scheduled"; + /// At least one of SupportsClasses/SupportsBooking must be true. + public bool SupportsClasses { get; set; } = true; + public bool SupportsBooking { get; set; } = false; public string? Description { get; set; } - /// Required (meaningfully) when BookingMode == "Scheduled". + /// Required (meaningfully) when SupportsClasses. [Range(1, int.MaxValue, ErrorMessage = "DefaultCapacity must be at least 1")] public int DefaultCapacity { get; set; } = 1; - /// Required (meaningfully) when BookingMode == "AdHoc" -- e.g. 3 for "3 courts". + /// Required (meaningfully) when SupportsBooking -- e.g. 3 for "3 courts". [Range(1, int.MaxValue, ErrorMessage = "NumberOfUnits must be at least 1")] public int NumberOfUnits { get; set; } = 1; @@ -30,7 +30,8 @@ namespace Fitness_core_BE.DTOs.Facility [Required] public Guid FacilityId { 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 int? DefaultCapacity { get; set; } public int? NumberOfUnits { get; set; } diff --git a/Helpers/TimeZoneHelper.cs b/Helpers/TimeZoneHelper.cs new file mode 100644 index 0000000..8abfa02 --- /dev/null +++ b/Helpers/TimeZoneHelper.cs @@ -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"); + } + } + } + } +} diff --git a/Interfaces/IBookingRepository.cs b/Interfaces/IBookingRepository.cs index 39d3a60..d46c544 100644 --- a/Interfaces/IBookingRepository.cs +++ b/Interfaces/IBookingRepository.cs @@ -14,7 +14,7 @@ namespace Fitness_core_BE.Interfaces Task> GetBookingsForCustomer(Guid customerId); Task CustomerHasActiveBookingForSession(Guid facilitySessionId, Guid customerId); - /// Unified roster query covering both Scheduled and AdHoc bookings -- backs + /// Unified roster query covering both Scheduled and WalkIn bookings -- backs /// GET api/client/bookings. All filters are optional/AND-combined. Task> GetBookings(Guid clientId, Guid? facilityId, Guid? facilitySessionId, DateTime? fromUtc, DateTime? toUtc, string? status); diff --git a/Interfaces/ICustomerSubscriptionRepository.cs b/Interfaces/ICustomerSubscriptionRepository.cs index 3ec887f..97f1174 100644 --- a/Interfaces/ICustomerSubscriptionRepository.cs +++ b/Interfaces/ICustomerSubscriptionRepository.cs @@ -23,14 +23,31 @@ namespace Fitness_core_BE.Interfaces Task GetCardByIdAsync(Guid cardId, CancellationToken ct = default); Task GetCustomerSubscriptionById(Guid subscriptionId); Task> GetCustomerSubscriptionsByCustomerId(Guid customerId); - Task GetActiveSubscriptionByCustomerId(Guid customerId); + /// True for an ordinary plan (no CustomerPlanClass links) or a class-linked + /// plan with GrantsGymAccess == true. + Task PlanGrantsGymAccessAsync(Guid customerPlanId, CancellationToken ct = default); + + /// 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. + Task GetActiveGymAccessSubscriptionAsync(Guid customerId); + + /// 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). + Task GetActiveSubscriptionForPlanAsync(Guid customerId, Guid customerPlanId); + + /// 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. + Task GetActiveSubscriptionForClassAsync(Guid customerId, Guid facilityClassId); // Whether this customer is currently an active member (primary OR secondary/group) of - // ANY subscription at this gym. GetActiveSubscriptionByCustomerId only sees subscriptions - // where they're the primary -- this also catches a group co-member, so "one customer, one - // plan at a time" can be enforced everywhere a plan gets assigned, not just the primary - // path. - Task HasAnyActiveSubscriptionAsync(Guid customerId, CancellationToken ct = default); + // ANY gym-access subscription at this gym -- a class-only subscription never counts, so + // unlimited concurrent class-only subscriptions are allowed. "One customer, one + // GYM-ACCESS plan at a time" is enforced everywhere a gym plan gets assigned, not just + // the primary path. + Task HasActiveGymAccessPlanAsync(Guid customerId, CancellationToken ct = default); Task AddSubscriptionMemberAsync(SubscriptionMember member); Task GetActiveMemberCountAsync(Guid subscriptionId); diff --git a/Interfaces/IFacilityClassRepository.cs b/Interfaces/IFacilityClassRepository.cs new file mode 100644 index 0000000..ded3963 --- /dev/null +++ b/Interfaces/IFacilityClassRepository.cs @@ -0,0 +1,42 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface IFacilityClassRepository + { + Task CreateFacilityClass(FacilityClass facilityClass); + Task> GetFacilityClasses(Guid clientId, bool includeInactive = false); + Task GetFacilityClassById(Guid facilityClassId); + Task UpdateFacilityClass(FacilityClass facilityClass); + + /// Active, non-Custom classes whose OccurrencesGeneratedThrough hasn't reached + /// the horizon yet -- Custom classes are never generated, their occurrences are always + /// written explicitly. + Task> GetClassesNeedingGenerationAsync(Guid clientId, DateTime horizon); + + /// 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. + Task> GetExistingOccurrenceStartTimesAsync(Guid facilityClassId, DateTime from, DateTime to); + + Task AddOccurrencesAsync(IEnumerable occurrences); + + Task> GetOccurrencesForClassAsync(Guid facilityClassId, bool includeCancelled); + + /// 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. + Task DeleteFutureUnbookedOccurrencesAsync(Guid facilityClassId, DateTime nowUtc); + + /// 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. + Task> GetSchedulingConflictsAsync(Guid clientId); + + /// 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. + Task LinkPlanToClassAsync(Guid customerPlanId, Guid facilityClassId); + } +} diff --git a/Interfaces/IFacilityRepository.cs b/Interfaces/IFacilityRepository.cs index 2d49d34..3a5b1fa 100644 --- a/Interfaces/IFacilityRepository.cs +++ b/Interfaces/IFacilityRepository.cs @@ -12,9 +12,14 @@ namespace Fitness_core_BE.Interfaces Task CreateFacilitySession(FacilitySession session); Task GetFacilitySessionById(Guid facilitySessionId); - Task> GetSessions(Guid clientId, Guid? facilityId, DateTime fromUtc, DateTime toUtc); + Task> GetSessions(Guid clientId, Guid? facilityId, DateTime fromUtc, DateTime toUtc, bool includeCancelled = false); Task GetActiveBookingCount(Guid facilitySessionId); - Task CancelFacilitySession(Guid facilitySessionId, Guid clientId); + Task> GetOverlappingActiveSessionsAsync(Guid facilityId, DateTime startUtc, DateTime endUtc); + + /// "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. + Task MarkFacilitySessionNotHeldAsync(Guid facilitySessionId, Guid clientId, string? reason); // Device binding (absorbed from the old IPlaceRepo -- one AccessDevice per Facility). Task GetDeviceByIdForClientAsync(Guid deviceId, Guid clientId); @@ -30,15 +35,15 @@ namespace Fitness_core_BE.Interfaces Task>> GetFacilityIdsByPlanIdsAsync(List customerPlanIds); Task> GetFacilityNamesByIdsAsync(List 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. - /// Counts non-cancelled/non-completed AdHoc bookings on this facility whose + /// Counts non-cancelled/non-completed WalkIn bookings on this facility whose /// [StartTime, EndTime) overlaps the given window. Pass excludeBookingId when /// re-checking a booking being rescheduled, so it doesn't count against itself. - Task GetOverlappingAdhocBookingCountAsync(Guid facilityId, DateTime startTime, DateTime endTime, Guid? excludeBookingId = null); + Task GetOverlappingWalkInBookingCountAsync(Guid facilityId, DateTime startTime, DateTime endTime, Guid? excludeBookingId = null); /// 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. Task LockFacilityForUpdateAsync(Guid facilityId); } diff --git a/Interfaces/Services/IBookingService.cs b/Interfaces/Services/IBookingService.cs index 6d458c8..f0247a8 100644 --- a/Interfaces/Services/IBookingService.cs +++ b/Interfaces/Services/IBookingService.cs @@ -1,21 +1,25 @@ +using Fitness_core_BE.Models; using Microsoft.AspNetCore.Http; namespace Fitness_core_BE.Interfaces.Services { public interface IBookingService { + /// Shared cancel+refund core -- see BookingService.CancelBookingCore. + Task CancelBookingCore(Booking booking); + /// Logged-in member books a session against their own plan's allowance. Task CreateMemberBooking(object payload, HttpContext httpContext); /// No membership at all -- pay-per-session, identity proof is the returned OTP. Task CreateGuestBooking(object payload, HttpContext httpContext); - /// Member books an AdHoc-mode facility directly for an arbitrary time window, no + /// Member books a WalkIn-mode facility directly for an arbitrary time window, no /// pre-created session. - Task CreateMemberAdhocBooking(object payload, HttpContext httpContext); + Task CreateMemberWalkInBooking(object payload, HttpContext httpContext); - /// Guest/staff walk-in books an AdHoc-mode facility directly. - Task CreateGuestAdhocBooking(object payload, HttpContext httpContext); + /// Guest/staff walk-in books a WalkIn-mode facility directly. + Task CreateGuestWalkInBooking(object payload, HttpContext httpContext); /// 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 @@ -36,7 +40,7 @@ namespace Fitness_core_BE.Interfaces.Services /// no cancel/recreate needed. Guest bookings only. Task ReassignBooking(object payload, HttpContext httpContext); - /// Staff moves a Confirmed booking to a different time (AdHoc) or session + /// Staff moves a Confirmed booking to a different time (WalkIn) or session /// (Scheduled), only onto a slot with room. Task RescheduleBooking(object payload, HttpContext httpContext); diff --git a/Interfaces/Services/IClassService.cs b/Interfaces/Services/IClassService.cs new file mode 100644 index 0000000..81fb769 --- /dev/null +++ b/Interfaces/Services/IClassService.cs @@ -0,0 +1,18 @@ +namespace Fitness_core_BE.Interfaces.Services +{ + public interface IClassService + { + Task GetFacilityClasses(object payload, HttpContext httpContext); + Task CreateFacilityClass(object payload, HttpContext httpContext); + Task UpdateFacilityClass(object payload, HttpContext httpContext); + Task GetOccurrences(object payload, HttpContext httpContext); + Task GetSchedulingConflicts(object payload, HttpContext httpContext); + + /// 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. + Task EnsureOccurrencesGeneratedAsync(Guid clientId); + } +} diff --git a/Models/Booking.cs b/Models/Booking.cs index ddb2a39..594a8cf 100644 --- a/Models/Booking.cs +++ b/Models/Booking.cs @@ -4,7 +4,7 @@ using System.ComponentModel.DataAnnotations.Schema; namespace Fitness_core_BE.Models { /// 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 /// 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 @@ -16,28 +16,28 @@ namespace Fitness_core_BE.Models [Key] public Guid BookingId { get; set; } - /// Null for an AdHoc booking (no pre-created session). + /// Null for a WalkIn booking (no pre-created session). public Guid? FKFacilitySessionId { get; set; } [ForeignKey(nameof(FKFacilitySessionId))] public FacilitySession? FacilitySession { get; set; } /// 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) - /// 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. public Guid FKFacilityId { get; set; } [ForeignKey(nameof(FKFacilityId))] public Facility Facility { get; set; } /// Copied from FacilitySession.StartTime (Scheduled) or the requested start - /// (AdHoc) at creation; updated in place by Reschedule. + /// (WalkIn) at creation; updated in place by Reschedule. public DateTime StartTime { get; set; } /// Same pattern as StartTime. public DateTime EndTime { get; set; } - /// Informational headcount for an AdHoc booking (e.g. "how many will come") -- - /// never enforced as a capacity mechanic; each AdHoc booking claims one whole unit + /// Informational headcount for a WalkIn booking (e.g. "how many will come") -- + /// never enforced as a capacity mechanic; each WalkIn booking claims one whole unit /// regardless of party size. Null for Scheduled bookings. public int? PartySize { get; set; } diff --git a/Models/CustomerPlan.cs b/Models/CustomerPlan.cs index 3c6cd08..ef8d385 100644 --- a/Models/CustomerPlan.cs +++ b/Models/CustomerPlan.cs @@ -63,5 +63,14 @@ namespace Fitness_core_BE.Models public bool IsActive { get; set; } = true; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// 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. + public bool GrantsGymAccess { get; set; } = true; } } diff --git a/Models/CustomerPlanClass.cs b/Models/CustomerPlanClass.cs new file mode 100644 index 0000000..256f602 --- /dev/null +++ b/Models/CustomerPlanClass.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + /// 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. + 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!; + } +} diff --git a/Models/Facility.cs b/Models/Facility.cs index a4ef08c..e2a78bb 100644 --- a/Models/Facility.cs +++ b/Models/Facility.cs @@ -21,25 +21,30 @@ namespace Fitness_core_BE.Models [MaxLength(128)] public string Name { get; set; } = string.Empty; - /// "Scheduled" | "AdHoc". Scheduled: staff pre-creates fixed-time - /// FacilitySessions that many bookings share one capacity pool against (classes). - /// AdHoc: a booking claims one of NumberOfUnits identical units directly for an - /// arbitrary time window, no pre-created session needed (courts/pool). - public string BookingMode { get; set; } = "Scheduled"; + /// Staff pre-creates fixed-time FacilitySessions (classes) that many bookings + /// share one capacity pool against. Independent of SupportsBooking -- both can be true, + /// e.g. a court that hosts a scheduled class AND accepts ad-hoc walk-in bookings outside + /// class hours. + public bool SupportsClasses { get; set; } = true; + + /// 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. + public bool SupportsBooking { get; set; } = false; [MaxLength(500)] public string? Description { get; set; } - /// Default per-session headcount limit when BookingMode == "Scheduled"; a - /// specific FacilitySession can override. + /// Default per-session headcount limit when SupportsClasses; a specific + /// FacilitySession can override. public int DefaultCapacity { get; set; } = 1; - /// Number of identical, independently-bookable units when BookingMode == - /// "AdHoc" (e.g. 3 for "3 courts") -- availability is this minus overlapping - /// non-cancelled/non-completed AdHoc bookings in the requested window. + /// Number of identical, independently-bookable units when SupportsBooking (e.g. + /// 3 for "3 courts") -- availability is this minus overlapping non-cancelled/ + /// non-completed WalkIn bookings in the requested window. public int NumberOfUnits { get; set; } = 1; - /// Hourly rate used to compute a guest AdHoc booking's suggested AmountCharged + /// Hourly rate used to compute a guest WalkIn booking's suggested AmountCharged /// (PricePerHour * duration-in-hours) at creation time. public decimal PricePerHour { get; set; } = 0m; diff --git a/Models/FacilityClass.cs b/Models/FacilityClass.cs new file mode 100644 index 0000000..72ae71b --- /dev/null +++ b/Models/FacilityClass.cs @@ -0,0 +1,71 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + /// 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. + 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; } + + /// "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. + [MaxLength(20)] + public string RecurrenceType { get; set; } = "Weekly"; + + /// 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. + public TimeSpan StartTimeOfDay { get; set; } + public int DurationMinutes { get; set; } = 60; + + /// Weekly only. Bit flags, Sunday = 1 (bit 0) through Saturday = 64 (bit 6). + public int WeekDaysMask { get; set; } = 0; + + /// Monthly only. Comma-separated day-of-month list, e.g. "1,15". + [MaxLength(200)] + public string? MonthDays { get; set; } + + /// Unused for Custom (occurrences are explicit, not generated from a range). + public DateTime SeriesStartDate { get; set; } = DateTime.UtcNow.Date; + /// Null = ongoing, no end date. Unused for Custom. + public DateTime? SeriesEndDate { get; set; } + + /// Null = unlimited (see IsUnlimitedCapacity); otherwise the per-occurrence + /// headcount cap, same meaning as Facility.DefaultCapacity/FacilitySession.Capacity. + public int? Capacity { get; set; } + + /// 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. + public bool IsUnlimitedCapacity { get; set; } = false; + + public bool IsActive { get; set; } = true; + + /// 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). + public DateTime OccurrencesGeneratedThrough { get; set; } = DateTime.UtcNow.Date; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Models/FacilitySession.cs b/Models/FacilitySession.cs index dcf71aa..db38071 100644 --- a/Models/FacilitySession.cs +++ b/Models/FacilitySession.cs @@ -22,12 +22,30 @@ namespace Fitness_core_BE.Models public DateTime StartTime { get; set; } public DateTime EndTime { get; set; } - /// Overrides Facility.DefaultCapacity when set; null = use the facility default. + /// Overrides Facility.DefaultCapacity when set; null = use the facility default. + /// Ignored when IsUnlimitedCapacity is true. public int? Capacity { get; set; } + /// 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. + public bool IsUnlimitedCapacity { get; set; } = false; + public bool IsCancelled { get; set; } = false; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + /// Null for a manually-created session (the pre-Classes path, unaffected). + /// Set for a class occurrence -- which FacilityClass generated or explicitly added it. + public Guid? FKFacilityClassId { get; set; } + [ForeignKey(nameof(FKFacilityClassId))] + public FacilityClass? FacilityClass { get; set; } + + /// 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. + public string? NotHeldReason { get; set; } + public DateTime? NotHeldAt { get; set; } + public virtual ICollection Bookings { get; set; } } } diff --git a/Program.cs b/Program.cs index 1333df1..f7e2fde 100644 --- a/Program.cs +++ b/Program.cs @@ -141,6 +141,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -164,6 +165,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Repos/ClientRepository.cs b/Repos/ClientRepository.cs index ddf5179..74ab98d 100644 --- a/Repos/ClientRepository.cs +++ b/Repos/ClientRepository.cs @@ -354,12 +354,22 @@ namespace Fitness_core_BE.Repos // Unlike CustomerSubscription.FKCustomerId (which only ever points at the primary // member), this resolves the caller's own SubscriptionMember row whether they're // 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 GetActiveMembershipForCustomerAsync(Guid customerId, CancellationToken ct = default) { return await _dbContext.SubscriptionMembers .Include(m => m.Subscription) .ThenInclude(s => s.CustomerPlan) .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) .FirstOrDefaultAsync(ct); } diff --git a/Repos/CustomerRepository.cs b/Repos/CustomerRepository.cs index 3f13ac6..2179c1f 100644 --- a/Repos/CustomerRepository.cs +++ b/Repos/CustomerRepository.cs @@ -116,7 +116,7 @@ namespace Fitness_core_BE.Repos } // 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. public Task GetTenantCustomerLinkById(Guid id) => _context.TenantCustomerLink diff --git a/Repos/CustomerSubscriptionRepository.cs b/Repos/CustomerSubscriptionRepository.cs index 7cbc4ac..1ba5c90 100644 --- a/Repos/CustomerSubscriptionRepository.cs +++ b/Repos/CustomerSubscriptionRepository.cs @@ -134,23 +134,69 @@ namespace Fitness_core_BE.Repos .ToListAsync(); } - public async Task 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 GymAccessPlans() => _context.CustomerPlans.Where(p => + !_context.CustomerPlanClasses.Any(c => c.FKCustomerPlanId == p.CustomerPlanId) || p.GrantsGymAccess); + + public Task PlanGrantsGymAccessAsync(Guid customerPlanId, CancellationToken ct = default) + => GymAccessPlans().AnyAsync(p => p.CustomerPlanId == customerPlanId, ct); + + public async Task GetActiveGymAccessSubscriptionAsync(Guid customerId) { return await _context.CustomerSubscriptions .Include(s => s.TenantCustomerLink) .Include(s => s.CustomerPlan) .Where(s => s.FKCustomerId == customerId && s.IsActive) + .Where(s => GymAccessPlans().Any(p => p.CustomerPlanId == s.FKCustomerPlanId)) .OrderByDescending(s => s.CreatedAt) .FirstOrDefaultAsync(); } + public async Task 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(); + } + + /// 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. + public async Task 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 // adds it alongside the CustomerSubscription itself), so this one query covers both - // "primary somewhere" and "secondary/group member somewhere" in a single check. - public Task HasAnyActiveSubscriptionAsync(Guid customerId, CancellationToken ct = default) + // "primary somewhere" and "secondary/group member somewhere" in a single check. Scoped + // 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 HasActiveGymAccessPlanAsync(Guid customerId, CancellationToken ct = default) { 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 ---------- diff --git a/Repos/FacilityClassRepository.cs b/Repos/FacilityClassRepository.cs new file mode 100644 index 0000000..538c5ee --- /dev/null +++ b/Repos/FacilityClassRepository.cs @@ -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 CreateFacilityClass(FacilityClass facilityClass) + { + facilityClass.FacilityClassId = Guid.NewGuid(); + facilityClass.CreatedAt = DateTime.UtcNow; + facilityClass.IsActive = true; + + _context.FacilityClasses.Add(facilityClass); + return facilityClass; + } + + public async Task> 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 GetFacilityClassById(Guid facilityClassId) + => _context.FacilityClasses + .Include(fc => fc.Facility) + .FirstOrDefaultAsync(fc => fc.FacilityClassId == facilityClassId); + + public async Task 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> GetClassesNeedingGenerationAsync(Guid clientId, DateTime horizon) + => _context.FacilityClasses + .Where(fc => fc.FKClientId == clientId && fc.IsActive && fc.RecurrenceType != "Custom" + && fc.OccurrencesGeneratedThrough < horizon) + .ToListAsync(); + + public async Task> 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 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> 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 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> 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(); + } + } +} diff --git a/Repos/FacilityRepository.cs b/Repos/FacilityRepository.cs index e6c3dfb..c72ae6d 100644 --- a/Repos/FacilityRepository.cs +++ b/Repos/FacilityRepository.cs @@ -41,7 +41,8 @@ namespace Fitness_core_BE.Repos ?? throw new KeyNotFoundException("Facility not found"); existing.Name = facility.Name; - existing.BookingMode = facility.BookingMode; + existing.SupportsClasses = facility.SupportsClasses; + existing.SupportsBooking = facility.SupportsBooking; existing.Description = facility.Description; existing.DefaultCapacity = facility.DefaultCapacity; existing.NumberOfUnits = facility.NumberOfUnits; @@ -76,12 +77,15 @@ namespace Fitness_core_BE.Repos .Include(s => s.Facility) .FirstOrDefaultAsync(s => s.FacilitySessionId == facilitySessionId); - public async Task> GetSessions(Guid clientId, Guid? facilityId, DateTime fromUtc, DateTime toUtc) + public async Task> GetSessions(Guid clientId, Guid? facilityId, DateTime fromUtc, DateTime toUtc, bool includeCancelled = false) { var query = _context.FacilitySessions .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) query = query.Where(s => s.FKFacilityId == facilityId.Value); @@ -91,13 +95,24 @@ namespace Fitness_core_BE.Repos public Task GetActiveBookingCount(Guid facilitySessionId) => _context.Bookings.CountAsync(b => b.FKFacilitySessionId == facilitySessionId && b.Status != "Cancelled"); - public async Task CancelFacilitySession(Guid facilitySessionId, Guid clientId) + /// 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. + public Task> 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 MarkFacilitySessionNotHeldAsync(Guid facilitySessionId, Guid clientId, string? reason) { var session = await _context.FacilitySessions + .Include(s => s.Bookings) .FirstOrDefaultAsync(s => s.FacilitySessionId == facilitySessionId && s.FKClientId == clientId); if (session == null) return null; session.IsCancelled = true; + session.NotHeldReason = reason; + session.NotHeldAt = DateTime.UtcNow; return session; } @@ -188,13 +203,13 @@ namespace Fitness_core_BE.Repos .ToDictionary(g => g.Key, g => g.Select(pf => pf.FKFacilityId).ToList()); } - // --- AdHoc booking support (new) --- + // --- WalkIn booking support (new) --- - public Task GetOverlappingAdhocBookingCountAsync(Guid facilityId, DateTime startTime, DateTime endTime, Guid? excludeBookingId = null) + public Task GetOverlappingWalkInBookingCountAsync(Guid facilityId, DateTime startTime, DateTime endTime, Guid? excludeBookingId = null) { var query = _context.Bookings.Where(b => 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.StartTime < endTime && b.EndTime > startTime); diff --git a/Services/BookingService.cs b/Services/BookingService.cs index 0ea8561..7d6fe0c 100644 --- a/Services/BookingService.cs +++ b/Services/BookingService.cs @@ -50,8 +50,14 @@ namespace Fitness_core_BE.Services if (await _bookingRepository.CustomerHasActiveBookingForSession(facilitySessionId, cusId)) throw new ArgumentException("You already have a booking for this session"); - var subscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(cusId) - ?? throw new ArgumentException("No active membership plan found"); + // 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"); // Null SessionsPerCycle = unlimited-access plan (existing gym behavior) -- // no allowance to check. A set value caps how many Facility sessions this @@ -70,10 +76,13 @@ namespace Fitness_core_BE.Services throw new ArgumentException("You've used all the sessions included in your current plan cycle"); } - var capacity = session.Capacity ?? session.Facility?.DefaultCapacity ?? 1; - var bookedCount = await _facilityRepository.GetActiveBookingCount(facilitySessionId); - if (bookedCount >= capacity) - throw new ArgumentException("This session is fully booked"); + if (!session.IsUnlimitedCapacity) + { + var capacity = session.Capacity ?? session.Facility?.DefaultCapacity ?? 1; + var bookedCount = await _facilityRepository.GetActiveBookingCount(facilitySessionId); + if (bookedCount >= capacity) + throw new ArgumentException("This session is fully booked"); + } var booking = new Booking { @@ -124,10 +133,13 @@ namespace Fitness_core_BE.Services if (session.StartTime <= DateTime.UtcNow) throw new ArgumentException("This session has already started"); - var capacity = session.Capacity ?? session.Facility?.DefaultCapacity ?? 1; - var bookedCount = await _facilityRepository.GetActiveBookingCount(facilitySessionId); - if (bookedCount >= capacity) - throw new ArgumentException("This session is fully booked"); + if (!session.IsUnlimitedCapacity) + { + var capacity = session.Capacity ?? session.Facility?.DefaultCapacity ?? 1; + var bookedCount = await _facilityRepository.GetActiveBookingCount(facilitySessionId); + if (bookedCount >= capacity) + throw new ArgumentException("This session is fully booked"); + } var booking = new Booking { @@ -161,14 +173,14 @@ namespace Fitness_core_BE.Services } } - // ---------- AdHoc-mode creation (new) ---------- + // ---------- WalkIn-mode creation (new) ---------- - public async Task CreateMemberAdhocBooking(object payload, HttpContext httpContext) + public async Task CreateMemberWalkInBooking(object payload, HttpContext httpContext) { await _uow.BeginAsync(); 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)); @@ -201,12 +213,12 @@ namespace Fitness_core_BE.Services } } - public async Task CreateGuestAdhocBooking(object payload, HttpContext httpContext) + public async Task CreateGuestWalkInBooking(object payload, HttpContext httpContext) { await _uow.BeginAsync(); 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 guestName = data["guestName"].GetString() ?? throw new ArgumentException("guestName is required"); @@ -249,11 +261,11 @@ namespace Fitness_core_BE.Services } } - /// Shared validation for both AdHoc creation paths: facility must be AdHoc-mode, - /// requested window must have a free unit. Locks the facility row for the duration of the - /// caller's transaction so two concurrent claims on the same facility serialize instead of - /// both reading "room available" before either commits. - private async Task<(Facility facility, DateTime startTime, DateTime endTime, int? partySize, decimal? pricePerHour)> ValidateAdhocRequestAsync(object payload) + /// Shared validation for both WalkIn creation paths: facility must support + /// walk-in booking, requested window must have a free unit. Locks the facility row for + /// the duration of the caller's transaction so two concurrent claims on the same facility + /// serialize instead of both reading "room available" before either commits. + private async Task<(Facility facility, DateTime startTime, DateTime endTime, int? partySize, decimal? pricePerHour)> ValidateWalkInRequestAsync(object payload) { var data = DeserializePayload.Deserialize(payload); var facilityId = Guid.Parse(data["facilityId"].GetString()!); @@ -276,13 +288,20 @@ namespace Fitness_core_BE.Services var facility = await _facilityRepository.GetFacilityById(facilityId) ?? throw new KeyNotFoundException("Facility not found"); - if (facility.BookingMode != "AdHoc") - throw new ArgumentException("This facility does not support ad-hoc booking"); + if (!facility.SupportsBooking) + throw new ArgumentException("This facility does not support walk-in booking"); if (!facility.IsActive) throw new ArgumentException("This facility is not active"); 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) throw new ArgumentException("No units are free for the requested time"); @@ -291,7 +310,7 @@ namespace Fitness_core_BE.Services /// 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 - /// 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 /// an otherwise-ordinary guest booking. private async Task ResolveOptionalCustomerLinkAsync(Dictionary data) @@ -448,18 +467,7 @@ namespace Fitness_core_BE.Services if (booking.Status == "CheckedIn") throw new ArgumentException("This booking has already been checked in and can't be cancelled"); - booking.Status = "Cancelled"; - - // 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 CancelBookingCore(booking); await _uow.CommitAsync(); return MapToDto(booking, includeOtp: false); @@ -471,6 +479,29 @@ namespace Fitness_core_BE.Services } } + /// 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. + 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 ReassignBooking(object payload, HttpContext httpContext) { await _uow.BeginAsync(); @@ -514,9 +545,9 @@ namespace Fitness_core_BE.Services 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")) - 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 durationMinutes = data["durationMinutes"].GetInt32(); @@ -524,7 +555,15 @@ namespace Fitness_core_BE.Services var endTime = startTime.AddMinutes(durationMinutes); 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); var facility = booking.Facility ?? await _facilityRepository.GetFacilityById(booking.FKFacilityId) @@ -777,7 +816,9 @@ namespace Fitness_core_BE.Services FacilitySessionId = booking.FKFacilitySessionId, FacilityId = booking.FKFacilityId, 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, EndTime = booking.EndTime, PartySize = booking.PartySize, diff --git a/Services/ClassService.cs b/Services/ClassService.cs new file mode 100644 index 0000000..afbaba7 --- /dev/null +++ b/Services/ClassService.cs @@ -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 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 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 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 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 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(); + + var toAdd = new List(); + 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 data, string key = "customOccurrences") + { + if (!data.ContainsKey(key) || data[key].ValueKind != JsonValueKind.Array) return; + + var occurrences = new List(); + 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 + }; + } +} diff --git a/Services/CustomerPlanService.cs b/Services/CustomerPlanService.cs index 9393694..ff8759c 100644 --- a/Services/CustomerPlanService.cs +++ b/Services/CustomerPlanService.cs @@ -12,6 +12,7 @@ namespace Fitness_core_BE.Services.CustomerPlanService public class CustomerPlanService( ICustomerPlanRepository customerPlanRepository, IFacilityRepository facilityRepository, + IFacilityClassRepository facilityClassRepository, IUnitOfWork uow, UserRoleHelper userRoleHelper) : ICustomerPlanService { @@ -26,6 +27,7 @@ namespace Fitness_core_BE.Services.CustomerPlanService private readonly ICustomerPlanRepository _customerPlanRepository = customerPlanRepository; private readonly IFacilityRepository _facilityRepository = facilityRepository; + private readonly IFacilityClassRepository _facilityClassRepository = facilityClassRepository; private readonly IUnitOfWork _uow = uow; private readonly UserRoleHelper _userRoleHelper = userRoleHelper; @@ -101,6 +103,16 @@ namespace Fitness_core_BE.Services.CustomerPlanService Guid userId = DataExtractor.GetUserIdFromContext(httpContext); 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 { FKClientId = clientId, @@ -124,7 +136,8 @@ namespace Fitness_core_BE.Services.CustomerPlanService : null, SessionRolloverPolicy = data.ContainsKey("sessionRolloverPolicy") ? data["sessionRolloverPolicy"].GetString() ?? RolloverNone : RolloverNone, 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); @@ -132,6 +145,9 @@ namespace Fitness_core_BE.Services.CustomerPlanService var result = await _customerPlanRepository.CreateCustomerPlan(customerPlan); await _uow.SaveChangesAsync(); + if (facilityClassId.HasValue) + await _facilityClassRepository.LinkPlanToClassAsync(result.CustomerPlanId, facilityClassId.Value); + var placeIds = DataExtractor.GetOptionalGuidList(data, "placeIds"); if (placeIds != null) await _facilityRepository.SetPlanFacilitiesAsync(result.CustomerPlanId, placeIds); diff --git a/Services/CustomerService.cs b/Services/CustomerService.cs index 0a6bfc6..c767050 100644 --- a/Services/CustomerService.cs +++ b/Services/CustomerService.cs @@ -440,13 +440,27 @@ namespace Fitness_core_BE.Services.CustomerService // member is created without a plan — e.g. a group secondary added later). 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 // the create-existing-customer and edit-customer flows submit whatever plan // 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 - // second active subscription. Assigning a genuinely different plan belongs to - // ChangeMembershipPlan, which explicitly deactivates the old one first. - var existingActive = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(customerId); + // second active subscription. Assigning a genuinely different GYM plan + // belongs to ChangeMembershipPlan, which explicitly deactivates the old one + // 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) { alreadyAssigned = true; @@ -457,15 +471,18 @@ namespace Fitness_core_BE.Services.CustomerService } else { - // Not primary anywhere (checked above), but could still be an active - // secondary on someone else's group plan -- GetActiveSubscriptionByCustomerId - // can't see that (it only matches CustomerSubscription.FKCustomerId, the - // primary). One customer, one plan at a time either way. - var hasOtherActivePlan = await _customerSubscriptionRepository.HasAnyActiveSubscriptionAsync(customerId); - if (hasOtherActivePlan) - throw new ArgumentException("This customer already has an active plan. Remove them from it before assigning a new one."); + if (grantsGymAccess) + { + // Not primary anywhere (checked above), but could still be an active + // secondary on someone else's group plan -- GetActiveGymAccessSubscriptionAsync + // can't see that (it only matches CustomerSubscription.FKCustomerId, the + // primary). One customer, one GYM-ACCESS plan at a time; class-only + // plans are unrestricted. + 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."); + } - var customerPlanId = Guid.Parse(data["customerPlanId"].GetString()!); var onboardDate = DataExtractor.GetOptionalDateTime(data, "onboardDate"); var chargePayment = DataExtractor.GetOptionalBool(data, "chargePayment") ?? true; @@ -1403,7 +1420,7 @@ namespace Fitness_core_BE.Services.CustomerService Guid userId = DataExtractor.GetUserIdFromContext(httpContext); Guid cusId = await _userRoleHelper.GetCusId(userId); - var subscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(cusId); + var subscription = await _customerSubscriptionRepository.GetActiveGymAccessSubscriptionAsync(cusId); if (subscription == null) return new { hasSubscription = false }; @@ -1477,7 +1494,7 @@ namespace Fitness_core_BE.Services.CustomerService Guid userId = DataExtractor.GetUserIdFromContext(httpContext); Guid cusId = await _userRoleHelper.GetCusId(userId); - var customerSubscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(cusId); + var customerSubscription = await _customerSubscriptionRepository.GetActiveGymAccessSubscriptionAsync(cusId); if (customerSubscription != null) { @@ -1539,7 +1556,7 @@ namespace Fitness_core_BE.Services.CustomerService Guid userId = DataExtractor.GetUserIdFromContext(httpContext); Guid cusId = await _userRoleHelper.GetCusId(userId); - var subscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(cusId); + var subscription = await _customerSubscriptionRepository.GetActiveGymAccessSubscriptionAsync(cusId); if (subscription == null) throw new KeyNotFoundException("You don't have an active gym to unfollow."); diff --git a/Services/CustomerSubscriptionService.cs b/Services/CustomerSubscriptionService.cs index d726a16..4ff194e 100644 --- a/Services/CustomerSubscriptionService.cs +++ b/Services/CustomerSubscriptionService.cs @@ -182,7 +182,7 @@ namespace Fitness_core_BE.Services.CustomerSubscription } var customerId = Guid.Parse(data["customerId"].GetString()!); - var subscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(customerId); + var subscription = await _customerSubscriptionRepository.GetActiveGymAccessSubscriptionAsync(customerId); if (subscription == null) { @@ -257,11 +257,15 @@ namespace Fitness_core_BE.Services.CustomerSubscription if (alreadyMember != null) 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 - // plan" and "already a secondary on a different group", neither of which the - // check above sees (it only looks at this one subscription). - var hasOtherActivePlan = await _customerSubscriptionRepository.HasAnyActiveSubscriptionAsync(customerId); - if (hasOtherActivePlan) + // One customer, one GYM-ACCESS plan at a time -- catches both "already primary + // on their own plan" and "already a secondary on a different group", neither of + // which the check above sees (it only looks at this one subscription). Group + // plans are ordinary gym plans (no class links), so this is always the + // 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."); var member = await _customerSubscriptionRepository.AddSubscriptionMemberAsync(new SubscriptionMember diff --git a/Services/FacilityService.cs b/Services/FacilityService.cs index d3bfd21..89fc102 100644 --- a/Services/FacilityService.cs +++ b/Services/FacilityService.cs @@ -10,15 +10,22 @@ namespace Fitness_core_BE.Services { public class FacilityService : IFacilityService { - private static readonly string[] ValidBookingModes = ["Scheduled", "AdHoc"]; - private readonly IFacilityRepository _facilityRepository; + private readonly IClassService _classService; + private readonly IBookingService _bookingService; private readonly IUnitOfWork _uow; 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; + _classService = classService; + _bookingService = bookingService; _uow = uow; _userRoleHelper = userRoleHelper; } @@ -44,16 +51,17 @@ namespace Fitness_core_BE.Services var data = DeserializePayload.Deserialize(payload); if (!data.ContainsKey("name")) throw new KeyNotFoundException("name is required"); - var bookingMode = data.ContainsKey("bookingMode") ? data["bookingMode"].GetString() ?? "Scheduled" : "Scheduled"; - if (!ValidBookingModes.Contains(bookingMode)) - throw new ArgumentException($"bookingMode must be one of: {string.Join(", ", ValidBookingModes)}"); + var supportsClasses = data.ContainsKey("supportsClasses") ? data["supportsClasses"].GetBoolean() : true; + var supportsBooking = data.ContainsKey("supportsBooking") ? data["supportsBooking"].GetBoolean() : false; + if (!supportsClasses && !supportsBooking) + throw new ArgumentException("At least one of SupportsClasses/SupportsBooking must be true"); 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"); 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"); var pricePerHour = data.ContainsKey("pricePerHour") ? data["pricePerHour"].GetDecimal() : 0m; @@ -66,7 +74,8 @@ namespace Fitness_core_BE.Services { FKClientId = clientId, Name = data["name"].GetString() ?? "", - BookingMode = bookingMode, + SupportsClasses = supportsClasses, + SupportsBooking = supportsBooking, Description = data.ContainsKey("description") ? data["description"].GetString() : null, DefaultCapacity = defaultCapacity, NumberOfUnits = numberOfUnits, @@ -98,13 +107,16 @@ namespace Fitness_core_BE.Services ?? throw new KeyNotFoundException("Facility not found"); if (data.ContainsKey("name")) existing.Name = data["name"].GetString() ?? existing.Name; - if (data.ContainsKey("bookingMode")) - { - var bookingMode = data["bookingMode"].GetString() ?? existing.BookingMode; - if (!ValidBookingModes.Contains(bookingMode)) - throw new ArgumentException($"bookingMode must be one of: {string.Join(", ", ValidBookingModes)}"); - existing.BookingMode = bookingMode; - } + + // Validate on the merged result, not each incoming field in isolation -- e.g. + // flipping SupportsClasses off must still leave SupportsBooking (old or new) true. + var newSupportsClasses = data.ContainsKey("supportsClasses") ? data["supportsClasses"].GetBoolean() : existing.SupportsClasses; + var newSupportsBooking = data.ContainsKey("supportsBooking") ? data["supportsBooking"].GetBoolean() : existing.SupportsBooking; + 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("defaultCapacity")) { @@ -179,8 +191,8 @@ namespace Fitness_core_BE.Services if (facility == null || facility.FKClientId != clientId) throw new KeyNotFoundException("Facility not found"); - if (facility.BookingMode != "Scheduled") - throw new ArgumentException("Sessions can only be created for Scheduled-mode facilities"); + if (!facility.SupportsClasses) + throw new ArgumentException("Sessions can only be created for facilities that support classes"); var startTime = DateTime.Parse(data["startTime"].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 ? DateTime.Parse(data["toDate"].GetString()!).ToUniversalTime() : fromUtc.AddDays(7); + bool includeCancelled = data.ContainsKey("includeCancelled") && data["includeCancelled"].GetBoolean(); 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(); foreach (var session in sessions) @@ -242,6 +260,13 @@ namespace Fitness_core_BE.Services return result; } + /// "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. public async Task CancelFacilitySession(object payload, HttpContext httpContext) { await _uow.BeginAsync(); @@ -249,11 +274,16 @@ namespace Fitness_core_BE.Services { var data = DeserializePayload.Deserialize(payload); 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 session = await _facilityRepository.CancelFacilitySession(facilitySessionId, clientId) + var session = await _facilityRepository.MarkFacilitySessionNotHeldAsync(facilitySessionId, clientId, reason) ?? 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(); return MapToDto(session); } @@ -335,38 +365,55 @@ namespace Fitness_core_BE.Services var result = new List(); foreach (var facility in facilities) { - if (facility.BookingMode == "AdHoc") - { - var overlapping = await _facilityRepository.GetOverlappingAdhocBookingCountAsync(facility.FacilityId, startTime, endTime); - result.Add(new FacilityAvailabilityDto - { - FacilityId = facility.FacilityId, - FacilityName = facility.Name, - BookingMode = facility.BookingMode, - TotalUnits = facility.NumberOfUnits, - UnitsFree = Math.Max(0, facility.NumberOfUnits - overlapping), - PricePerHour = facility.PricePerHour - }); - } - 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 totalBooked = 0; - foreach (var s in overlappingSessions) - totalBooked += await _facilityRepository.GetActiveBookingCount(s.FacilitySessionId); + // 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); - result.Add(new FacilityAvailabilityDto - { - FacilityId = facility.FacilityId, - FacilityName = facility.Name, - BookingMode = facility.BookingMode, - TotalUnits = totalCapacity, - UnitsFree = Math.Max(0, totalCapacity - totalBooked), - PricePerHour = facility.PricePerHour - }); + int? bookingTotal = null, bookingFree = null, classTotal = null, classFree = null; + bool classIsUnlimited = false; + + if (facility.SupportsBooking) + { + var overlapping = await _facilityRepository.GetOverlappingWalkInBookingCountAsync(facility.FacilityId, startTime, endTime); + 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) + { + // An unlimited-capacity occurrence has no meaningful total -- Capacity stays + // null on it either way, so summing would silently under-count by falling + // back to the facility default instead of reporting "no cap" at all. + if (overlappingSessions.Any(s => s.IsUnlimitedCapacity)) + { + classIsUnlimited = true; + } + else + { + var totalCapacity = overlappingSessions.Sum(s => s.Capacity ?? facility.DefaultCapacity); + var totalBooked = 0; + foreach (var s in overlappingSessions) + totalBooked += await _facilityRepository.GetActiveBookingCount(s.FacilitySessionId); + + classTotal = totalCapacity; + classFree = Math.Max(0, totalCapacity - totalBooked); + } + } + + result.Add(new FacilityAvailabilityDto + { + FacilityId = facility.FacilityId, + FacilityName = facility.Name, + SupportsClasses = facility.SupportsClasses, + SupportsBooking = facility.SupportsBooking, + BookingTotalUnits = bookingTotal, + BookingUnitsFree = bookingFree, + ClassTotalCapacity = classTotal, + ClassUnitsFree = classFree, + ClassIsUnlimitedCapacity = classIsUnlimited, + PricePerHour = facility.PricePerHour + }); } return result; @@ -376,7 +423,8 @@ namespace Fitness_core_BE.Services { FacilityId = facility.FacilityId, Name = facility.Name, - BookingMode = facility.BookingMode, + SupportsClasses = facility.SupportsClasses, + SupportsBooking = facility.SupportsBooking, Description = facility.Description, DefaultCapacity = facility.DefaultCapacity, NumberOfUnits = facility.NumberOfUnits, @@ -395,8 +443,13 @@ namespace Fitness_core_BE.Services StartTime = session.StartTime, EndTime = session.EndTime, Capacity = session.Capacity ?? session.Facility?.DefaultCapacity ?? 1, + IsUnlimitedCapacity = session.IsUnlimitedCapacity, BookedCount = bookedCount, - IsCancelled = session.IsCancelled + IsCancelled = session.IsCancelled, + FacilityClassId = session.FKFacilityClassId, + FacilityClassName = session.FacilityClass?.Name, + NotHeldReason = session.NotHeldReason, + NotHeldAt = session.NotHeldAt }; } } diff --git a/Services/SubscriptionRenewalJob.cs b/Services/SubscriptionRenewalJob.cs index f7869a8..4d4e365 100644 --- a/Services/SubscriptionRenewalJob.cs +++ b/Services/SubscriptionRenewalJob.cs @@ -25,7 +25,7 @@ namespace Fitness_core_BE.Services // SubscriptionRenewalRunner still use DateTime.UtcNow, unaffected by this -- only the // wake-up point shifts. 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 ILogger _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 // run just because the app happened to restart. Internal + static so it's unit-testable // without spinning up the whole hosted service. diff --git a/TenantDbContext.cs b/TenantDbContext.cs index c6fc394..5f0cda6 100644 --- a/TenantDbContext.cs +++ b/TenantDbContext.cs @@ -73,6 +73,11 @@ namespace Fitness_core_BE public DbSet TimeBankCardUsageLogs => Set(); public DbSet CustomerPlanFacilities => Set(); + // Recurring class definitions + their generated FacilitySession occurrences (see + // Models/FacilityClass.cs), and which CustomerPlan(s) are sold against a class. + public DbSet FacilityClasses => Set(); + public DbSet CustomerPlanClasses => Set(); + // 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 // TimeBankCard's own, unrelated RfidTagId string field. @@ -197,6 +202,7 @@ namespace Fitness_core_BE // doesn't, since Client lives only in CentralDbContext). modelBuilder.Entity().Ignore(f => f.Client); modelBuilder.Entity().Ignore(s => s.Client); + modelBuilder.Entity().Ignore(fc => fc.Client); modelBuilder.Entity().Ignore(b => b.Client); modelBuilder.Entity().Ignore(i => i.Client); modelBuilder.Entity().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 // 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 - // 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 - // is naturally a no-op for AdHoc bookings -- their own overlap-vs-NumberOfUnits check - // lives in BookingRepository.GetOverlappingAdhocBookingCountAsync instead. + // is naturally a no-op for WalkIn bookings -- their own overlap-vs-NumberOfUnits check + // lives in FacilityRepository.GetOverlappingWalkInBookingCountAsync instead. modelBuilder.Entity() .HasIndex(b => new { b.FKFacilitySessionId, b.FKCustomerId }) .IsUnique() @@ -222,6 +228,17 @@ namespace Fitness_core_BE modelBuilder.Entity() .HasIndex(s => new { s.FKFacilityId, s.StartTime }); + modelBuilder.Entity() + .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() + .HasIndex(s => new { s.FKFacilityClassId, s.StartTime }) + .IsUnique(); + modelBuilder.Entity() .HasIndex(t => t.RfidTagId) .IsUnique(); @@ -283,15 +300,25 @@ namespace Fitness_core_BE .HasOne(pf => pf.Facility).WithMany() .HasForeignKey(pf => pf.FKFacilityId).OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasIndex(pc => new { pc.FKCustomerPlanId, pc.FKFacilityClassId }) + .IsUnique(); + modelBuilder.Entity() + .HasOne(pc => pc.CustomerPlan).WithMany() + .HasForeignKey(pc => pc.FKCustomerPlanId).OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .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 // club's pool. modelBuilder.Entity() .HasIndex(t => new { t.FKClientId, t.TagCode }) .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 - // 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 // simple unique index, and MySQL/Pomelo has no range-overlap index type. This index // just makes that overlap query efficient.