Facility booking-mode redesign, multi-plan subscriptions, and Classes

Implements the facility-classes-booking-dashboard plan (backend):

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 14:26:59 +05:30
parent 1a502767ba
commit 5d711bd13e
38 changed files with 1437 additions and 226 deletions
+105 -52
View File
@@ -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<FacilitySessionDto>();
foreach (var session in sessions)
@@ -242,6 +260,13 @@ namespace Fitness_core_BE.Services
return result;
}
/// <summary>"Mark not held" -- sets IsCancelled/NotHeldReason/NotHeldAt and cascades to
/// every Confirmed booking on the occurrence: cancels it and refunds any subscription
/// allowance, through the exact same core BookingService.CancelBooking uses (one refund
/// code path, not two). The freed time becomes bookable again automatically -- the
/// walk-in overlap guard only checks non-cancelled sessions. Same entry point as the old
/// bare-IsCancelled CancelFacilitySession this replaces; also reachable via the
/// memberships-gated facility-classes/.../not-held route for the Classes tab.</summary>
public async Task<object> CancelFacilitySession(object payload, HttpContext httpContext)
{
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<FacilityAvailabilityDto>();
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
};
}
}