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:
+81
-40
@@ -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<object> CreateMemberAdhocBooking(object payload, HttpContext httpContext)
|
||||
public async Task<object> 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<object> CreateGuestAdhocBooking(object payload, HttpContext httpContext)
|
||||
public async Task<object> 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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
private async Task<(Facility facility, DateTime startTime, DateTime endTime, int? partySize, decimal? pricePerHour)> ValidateAdhocRequestAsync(object payload)
|
||||
/// <summary>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.</summary>
|
||||
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
|
||||
|
||||
/// <summary>Staff can optionally identify a walk-in guest as an already-registered member
|
||||
/// (search-and-pick, not free-typed) -- purely for identification/auto-fill on the FE, see
|
||||
/// 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.</summary>
|
||||
private async Task<Guid?> ResolveOptionalCustomerLinkAsync(Dictionary<string, JsonElement> 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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Just the in-memory mutations (status + subscription allowance refund), no
|
||||
/// transaction demarcation or status validation -- called both by CancelBooking (after
|
||||
/// its own status checks) and by FacilityService's "mark not held" cascade (which does
|
||||
/// its own per-booking filtering before calling this for each affected booking), so
|
||||
/// there's one refund code path instead of two. Looks the subscription up by the exact
|
||||
/// id the booking drew its allowance from, rather than re-resolving "the customer's
|
||||
/// current active subscription" -- correct regardless of which kind of subscription
|
||||
/// (gym-access or class-linked) it was, and unaffected by the customer holding others.</summary>
|
||||
public async Task CancelBookingCore(Booking booking)
|
||||
{
|
||||
booking.Status = "Cancelled";
|
||||
|
||||
// Give the session allowance back -- only member bookings draw one down.
|
||||
if (!booking.IsGuest && booking.FKCustomerSubscriptionId.HasValue)
|
||||
{
|
||||
var subscription = await _customerSubscriptionRepository.GetCustomerSubscriptionById(booking.FKCustomerSubscriptionId.Value);
|
||||
if (subscription != null && subscription.CustomerPlan.SessionsPerCycle.HasValue && subscription.SessionsUsedThisCycle > 0)
|
||||
{
|
||||
subscription.SessionsUsedThisCycle--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> ReassignBooking(object payload, HttpContext httpContext)
|
||||
{
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
using Fitness_core_BE.DTOs.Facility;
|
||||
using Fitness_core_BE.Helpers;
|
||||
using Fitness_core_BE.Infra.UoW;
|
||||
using Fitness_core_BE.Interfaces;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
public class ClassService : IClassService
|
||||
{
|
||||
private static readonly string[] ValidRecurrenceTypes = ["Daily", "Weekly", "Monthly", "Custom"];
|
||||
private const int GenerationHorizonDays = 56; // 8 weeks
|
||||
|
||||
private readonly IFacilityClassRepository _classRepository;
|
||||
private readonly IFacilityRepository _facilityRepository;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly UserRoleHelper _userRoleHelper;
|
||||
|
||||
public ClassService(
|
||||
IFacilityClassRepository classRepository,
|
||||
IFacilityRepository facilityRepository,
|
||||
IUnitOfWork uow,
|
||||
UserRoleHelper userRoleHelper)
|
||||
{
|
||||
_classRepository = classRepository;
|
||||
_facilityRepository = facilityRepository;
|
||||
_uow = uow;
|
||||
_userRoleHelper = userRoleHelper;
|
||||
}
|
||||
|
||||
public async Task<object> GetFacilityClasses(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
bool includeInactive = data.ContainsKey("includeInactive") && data["includeInactive"].GetBoolean();
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var classes = await _classRepository.GetFacilityClasses(clientId, includeInactive);
|
||||
|
||||
return classes.Select(MapToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<object> CreateFacilityClass(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
|
||||
var facilityId = Guid.Parse(data["facilityId"].GetString()!);
|
||||
var facility = await _facilityRepository.GetFacilityById(facilityId);
|
||||
if (facility == null || facility.FKClientId != clientId)
|
||||
throw new KeyNotFoundException("Facility not found");
|
||||
if (!facility.SupportsClasses)
|
||||
throw new ArgumentException("This facility does not support classes");
|
||||
|
||||
if (!data.ContainsKey("name")) throw new KeyNotFoundException("name is required");
|
||||
|
||||
var recurrenceType = data.ContainsKey("recurrenceType") ? data["recurrenceType"].GetString() ?? "Weekly" : "Weekly";
|
||||
if (!ValidRecurrenceTypes.Contains(recurrenceType))
|
||||
throw new ArgumentException($"recurrenceType must be one of: {string.Join(", ", ValidRecurrenceTypes)}");
|
||||
|
||||
var isUnlimitedCapacity = data.ContainsKey("isUnlimitedCapacity") && data["isUnlimitedCapacity"].GetBoolean();
|
||||
int? capacity = data.ContainsKey("capacity") && data["capacity"].ValueKind != JsonValueKind.Null
|
||||
? data["capacity"].GetInt32() : null;
|
||||
if (!isUnlimitedCapacity && capacity.HasValue && capacity.Value < 1)
|
||||
throw new ArgumentException("capacity must be at least 1 when set");
|
||||
|
||||
var durationMinutes = data.ContainsKey("durationMinutes") ? data["durationMinutes"].GetInt32() : 60;
|
||||
if (durationMinutes < 1) throw new ArgumentException("durationMinutes must be at least 1");
|
||||
|
||||
var facilityClass = new FacilityClass
|
||||
{
|
||||
FKClientId = clientId,
|
||||
FKFacilityId = facilityId,
|
||||
Name = data["name"].GetString() ?? "",
|
||||
Description = data.ContainsKey("description") ? data["description"].GetString() : null,
|
||||
RecurrenceType = recurrenceType,
|
||||
DurationMinutes = durationMinutes,
|
||||
Capacity = isUnlimitedCapacity ? null : capacity,
|
||||
IsUnlimitedCapacity = isUnlimitedCapacity
|
||||
};
|
||||
|
||||
if (recurrenceType == "Custom")
|
||||
{
|
||||
// Occurrences are explicit -- no rule fields, no generation watermark ever
|
||||
// consulted for this class.
|
||||
facilityClass.OccurrencesGeneratedThrough = DateTime.UtcNow.Date;
|
||||
var result = await _classRepository.CreateFacilityClass(facilityClass);
|
||||
await _uow.SaveChangesAsync(); // persist the id before occurrences reference it
|
||||
|
||||
await AddCustomOccurrencesAsync(facilityClass, data);
|
||||
|
||||
await _uow.CommitAsync();
|
||||
result.Facility = facility;
|
||||
return MapToDto(result);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!data.ContainsKey("startTimeOfDay")) throw new KeyNotFoundException("startTimeOfDay is required");
|
||||
facilityClass.StartTimeOfDay = TimeSpan.Parse(data["startTimeOfDay"].GetString()!);
|
||||
facilityClass.WeekDaysMask = data.ContainsKey("weekDaysMask") ? data["weekDaysMask"].GetInt32() : 0;
|
||||
facilityClass.MonthDays = data.ContainsKey("monthDays") ? data["monthDays"].GetString() : null;
|
||||
facilityClass.SeriesStartDate = data.ContainsKey("seriesStartDate") && data["seriesStartDate"].ValueKind != JsonValueKind.Null
|
||||
? DateTime.Parse(data["seriesStartDate"].GetString()!).Date
|
||||
: DateTime.UtcNow.Date;
|
||||
facilityClass.SeriesEndDate = data.ContainsKey("seriesEndDate") && data["seriesEndDate"].ValueKind != JsonValueKind.Null
|
||||
? DateTime.Parse(data["seriesEndDate"].GetString()!).Date
|
||||
: null;
|
||||
|
||||
if (recurrenceType == "Weekly" && facilityClass.WeekDaysMask == 0)
|
||||
throw new ArgumentException("Select at least one day of the week");
|
||||
if (recurrenceType == "Monthly" && string.IsNullOrWhiteSpace(facilityClass.MonthDays))
|
||||
throw new ArgumentException("Select at least one day of the month");
|
||||
|
||||
// Nothing generated yet -- the next GetSessions call does it lazily.
|
||||
facilityClass.OccurrencesGeneratedThrough = facilityClass.SeriesStartDate.AddDays(-1);
|
||||
|
||||
var result = await _classRepository.CreateFacilityClass(facilityClass);
|
||||
await _uow.CommitAsync();
|
||||
result.Facility = facility;
|
||||
return MapToDto(result);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> UpdateFacilityClass(object payload, HttpContext httpContext)
|
||||
{
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var facilityClassId = Guid.Parse(data["facilityClassId"].GetString()!);
|
||||
|
||||
var existing = await _classRepository.GetFacilityClassById(facilityClassId)
|
||||
?? throw new KeyNotFoundException("Class not found");
|
||||
|
||||
if (data.ContainsKey("name")) existing.Name = data["name"].GetString() ?? existing.Name;
|
||||
if (data.ContainsKey("description")) existing.Description = data["description"].GetString();
|
||||
if (data.ContainsKey("isActive")) existing.IsActive = data["isActive"].GetBoolean();
|
||||
|
||||
if (data.ContainsKey("isUnlimitedCapacity")) existing.IsUnlimitedCapacity = data["isUnlimitedCapacity"].GetBoolean();
|
||||
if (data.ContainsKey("capacity"))
|
||||
{
|
||||
var capacity = data["capacity"].ValueKind != JsonValueKind.Null ? data["capacity"].GetInt32() : (int?)null;
|
||||
if (!existing.IsUnlimitedCapacity && capacity.HasValue && capacity.Value < 1)
|
||||
throw new ArgumentException("capacity must be at least 1 when set");
|
||||
existing.Capacity = existing.IsUnlimitedCapacity ? null : capacity;
|
||||
}
|
||||
|
||||
// Only these recurrence-rule fields trigger a regeneration -- editing the name,
|
||||
// description, capacity or active flag doesn't touch existing occurrences.
|
||||
var recurrenceKeys = new[] { "recurrenceType", "startTimeOfDay", "durationMinutes", "weekDaysMask", "monthDays", "seriesStartDate", "seriesEndDate" };
|
||||
var recurrenceChanged = recurrenceKeys.Any(data.ContainsKey);
|
||||
|
||||
if (recurrenceChanged && existing.RecurrenceType != "Custom")
|
||||
{
|
||||
if (data.ContainsKey("recurrenceType"))
|
||||
{
|
||||
var recurrenceType = data["recurrenceType"].GetString() ?? existing.RecurrenceType;
|
||||
if (!ValidRecurrenceTypes.Contains(recurrenceType))
|
||||
throw new ArgumentException($"recurrenceType must be one of: {string.Join(", ", ValidRecurrenceTypes)}");
|
||||
existing.RecurrenceType = recurrenceType;
|
||||
}
|
||||
if (data.ContainsKey("startTimeOfDay")) existing.StartTimeOfDay = TimeSpan.Parse(data["startTimeOfDay"].GetString()!);
|
||||
if (data.ContainsKey("durationMinutes"))
|
||||
{
|
||||
var durationMinutes = data["durationMinutes"].GetInt32();
|
||||
if (durationMinutes < 1) throw new ArgumentException("durationMinutes must be at least 1");
|
||||
existing.DurationMinutes = durationMinutes;
|
||||
}
|
||||
if (data.ContainsKey("weekDaysMask")) existing.WeekDaysMask = data["weekDaysMask"].GetInt32();
|
||||
if (data.ContainsKey("monthDays")) existing.MonthDays = data["monthDays"].GetString();
|
||||
if (data.ContainsKey("seriesStartDate") && data["seriesStartDate"].ValueKind != JsonValueKind.Null)
|
||||
existing.SeriesStartDate = DateTime.Parse(data["seriesStartDate"].GetString()!).Date;
|
||||
if (data.ContainsKey("seriesEndDate"))
|
||||
existing.SeriesEndDate = data["seriesEndDate"].ValueKind != JsonValueKind.Null
|
||||
? DateTime.Parse(data["seriesEndDate"].GetString()!).Date : null;
|
||||
|
||||
if (existing.RecurrenceType == "Weekly" && existing.WeekDaysMask == 0)
|
||||
throw new ArgumentException("Select at least one day of the week");
|
||||
if (existing.RecurrenceType == "Monthly" && string.IsNullOrWhiteSpace(existing.MonthDays))
|
||||
throw new ArgumentException("Select at least one day of the month");
|
||||
|
||||
// Anything past or already booked is left alone -- only future, unbooked
|
||||
// occurrences are cleared so the next read regenerates them under the new rule.
|
||||
await _classRepository.DeleteFutureUnbookedOccurrencesAsync(existing.FacilityClassId, DateTime.UtcNow);
|
||||
existing.OccurrencesGeneratedThrough = existing.SeriesStartDate > DateTime.UtcNow.Date
|
||||
? existing.SeriesStartDate.AddDays(-1)
|
||||
: DateTime.UtcNow.Date.AddDays(-1);
|
||||
}
|
||||
else if (existing.RecurrenceType == "Custom" && data.ContainsKey("addCustomOccurrences"))
|
||||
{
|
||||
await AddCustomOccurrencesAsync(existing, data, "addCustomOccurrences");
|
||||
}
|
||||
|
||||
var result = await _classRepository.UpdateFacilityClass(existing);
|
||||
await _uow.CommitAsync();
|
||||
return MapToDto(result);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _uow.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> GetOccurrences(object payload, HttpContext httpContext)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var facilityClassId = Guid.Parse(data["facilityClassId"].GetString()!);
|
||||
bool includeCancelled = data.ContainsKey("includeCancelled") && data["includeCancelled"].GetBoolean();
|
||||
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var facilityClass = await _classRepository.GetFacilityClassById(facilityClassId);
|
||||
if (facilityClass == null || facilityClass.FKClientId != clientId)
|
||||
throw new KeyNotFoundException("Class not found");
|
||||
|
||||
var occurrences = await _classRepository.GetOccurrencesForClassAsync(facilityClassId, includeCancelled);
|
||||
return occurrences.Select(o => new
|
||||
{
|
||||
facilitySessionId = o.FacilitySessionId,
|
||||
startTime = o.StartTime,
|
||||
endTime = o.EndTime,
|
||||
capacity = o.Capacity,
|
||||
isUnlimitedCapacity = o.IsUnlimitedCapacity,
|
||||
isCancelled = o.IsCancelled,
|
||||
notHeldReason = o.NotHeldReason,
|
||||
notHeldAt = o.NotHeldAt
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public async Task<object> GetSchedulingConflicts(object payload, HttpContext httpContext)
|
||||
{
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
var conflicts = await _classRepository.GetSchedulingConflictsAsync(clientId);
|
||||
|
||||
return conflicts.Select(b => new SchedulingConflictDto
|
||||
{
|
||||
BookingId = b.BookingId,
|
||||
FacilityId = b.FKFacilityId,
|
||||
FacilityName = b.Facility?.Name,
|
||||
GuestName = b.IsGuest ? b.GuestName : null,
|
||||
StartTime = b.StartTime,
|
||||
EndTime = b.EndTime
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public async Task EnsureOccurrencesGeneratedAsync(Guid clientId)
|
||||
{
|
||||
var horizon = DateTime.UtcNow.Date.AddDays(GenerationHorizonDays);
|
||||
var classes = await _classRepository.GetClassesNeedingGenerationAsync(clientId, horizon);
|
||||
if (classes.Count == 0) return;
|
||||
|
||||
await _uow.BeginAsync();
|
||||
try
|
||||
{
|
||||
foreach (var facilityClass in classes)
|
||||
await GenerateOccurrencesForClassAsync(facilityClass, horizon);
|
||||
|
||||
await _uow.CommitAsync();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// Two concurrent calendar loads raced to generate the same occurrence -- the
|
||||
// unique (FKFacilityClassId, StartTime) index caught it. Roll back this whole
|
||||
// batch rather than partially applying it; the next read regenerates safely
|
||||
// (watermarks simply won't have advanced this one time).
|
||||
await _uow.RollbackAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GenerateOccurrencesForClassAsync(FacilityClass facilityClass, DateTime horizon)
|
||||
{
|
||||
var genFrom = facilityClass.OccurrencesGeneratedThrough > facilityClass.SeriesStartDate
|
||||
? facilityClass.OccurrencesGeneratedThrough.AddDays(1)
|
||||
: facilityClass.SeriesStartDate;
|
||||
var genTo = facilityClass.SeriesEndDate.HasValue && facilityClass.SeriesEndDate.Value < horizon
|
||||
? facilityClass.SeriesEndDate.Value
|
||||
: horizon;
|
||||
|
||||
if (genFrom > genTo)
|
||||
{
|
||||
facilityClass.OccurrencesGeneratedThrough = horizon;
|
||||
return;
|
||||
}
|
||||
|
||||
var existingStartTimes = await _classRepository.GetExistingOccurrenceStartTimesAsync(
|
||||
facilityClass.FacilityClassId,
|
||||
LocalToUtc(genFrom),
|
||||
LocalToUtc(genTo.AddDays(1)));
|
||||
|
||||
var monthDays = !string.IsNullOrWhiteSpace(facilityClass.MonthDays)
|
||||
? facilityClass.MonthDays.Split(',').Select(d => int.Parse(d.Trim())).ToHashSet()
|
||||
: new HashSet<int>();
|
||||
|
||||
var toAdd = new List<FacilitySession>();
|
||||
for (var date = genFrom.Date; date <= genTo.Date; date = date.AddDays(1))
|
||||
{
|
||||
bool runsToday = facilityClass.RecurrenceType switch
|
||||
{
|
||||
"Daily" => true,
|
||||
"Weekly" => (facilityClass.WeekDaysMask & (1 << (int)date.DayOfWeek)) != 0,
|
||||
"Monthly" => monthDays.Contains(date.Day),
|
||||
_ => false
|
||||
};
|
||||
if (!runsToday) continue;
|
||||
|
||||
var utcStart = LocalToUtc(date.Date + facilityClass.StartTimeOfDay);
|
||||
if (existingStartTimes.Contains(utcStart)) continue;
|
||||
|
||||
toAdd.Add(new FacilitySession
|
||||
{
|
||||
FKFacilityId = facilityClass.FKFacilityId,
|
||||
FKClientId = facilityClass.FKClientId,
|
||||
StartTime = utcStart,
|
||||
EndTime = utcStart.AddMinutes(facilityClass.DurationMinutes),
|
||||
Capacity = facilityClass.Capacity,
|
||||
IsUnlimitedCapacity = facilityClass.IsUnlimitedCapacity,
|
||||
FKFacilityClassId = facilityClass.FacilityClassId
|
||||
});
|
||||
}
|
||||
|
||||
if (toAdd.Count > 0)
|
||||
await _classRepository.AddOccurrencesAsync(toAdd);
|
||||
|
||||
facilityClass.OccurrencesGeneratedThrough = genTo;
|
||||
}
|
||||
|
||||
private static DateTime LocalToUtc(DateTime localUnspecified)
|
||||
=> TimeZoneInfo.ConvertTimeToUtc(DateTime.SpecifyKind(localUnspecified, DateTimeKind.Unspecified), TimeZoneHelper.SriLanka);
|
||||
|
||||
private async Task AddCustomOccurrencesAsync(FacilityClass facilityClass, Dictionary<string, JsonElement> data, string key = "customOccurrences")
|
||||
{
|
||||
if (!data.ContainsKey(key) || data[key].ValueKind != JsonValueKind.Array) return;
|
||||
|
||||
var occurrences = new List<FacilitySession>();
|
||||
foreach (var occEl in data[key].EnumerateArray())
|
||||
{
|
||||
var startTime = DateTime.Parse(occEl.GetProperty("startTime").GetString()!).ToUniversalTime();
|
||||
var durationMinutes = occEl.GetProperty("durationMinutes").GetInt32();
|
||||
if (durationMinutes < 1) throw new ArgumentException("durationMinutes must be at least 1");
|
||||
|
||||
occurrences.Add(new FacilitySession
|
||||
{
|
||||
FKFacilityId = facilityClass.FKFacilityId,
|
||||
FKClientId = facilityClass.FKClientId,
|
||||
StartTime = startTime,
|
||||
EndTime = startTime.AddMinutes(durationMinutes),
|
||||
Capacity = facilityClass.Capacity,
|
||||
IsUnlimitedCapacity = facilityClass.IsUnlimitedCapacity,
|
||||
FKFacilityClassId = facilityClass.FacilityClassId
|
||||
});
|
||||
}
|
||||
|
||||
if (occurrences.Count > 0)
|
||||
await _classRepository.AddOccurrencesAsync(occurrences);
|
||||
}
|
||||
|
||||
private static FacilityClassDto MapToDto(FacilityClass fc) => new()
|
||||
{
|
||||
FacilityClassId = fc.FacilityClassId,
|
||||
FacilityId = fc.FKFacilityId,
|
||||
FacilityName = fc.Facility?.Name,
|
||||
Name = fc.Name,
|
||||
Description = fc.Description,
|
||||
RecurrenceType = fc.RecurrenceType,
|
||||
StartTimeOfDay = fc.RecurrenceType == "Custom" ? null : fc.StartTimeOfDay.ToString("hh\\:mm"),
|
||||
DurationMinutes = fc.DurationMinutes,
|
||||
WeekDaysMask = fc.WeekDaysMask,
|
||||
MonthDays = fc.MonthDays,
|
||||
SeriesStartDate = fc.SeriesStartDate,
|
||||
SeriesEndDate = fc.SeriesEndDate,
|
||||
Capacity = fc.Capacity,
|
||||
IsUnlimitedCapacity = fc.IsUnlimitedCapacity,
|
||||
IsActive = fc.IsActive,
|
||||
CreatedAt = fc.CreatedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ namespace Fitness_core_BE.Services.CustomerPlanService
|
||||
public class CustomerPlanService(
|
||||
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);
|
||||
|
||||
+31
-14
@@ -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.");
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+105
-52
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SubscriptionRenewalJob> _logger;
|
||||
@@ -68,29 +68,6 @@ namespace Fitness_core_BE.Services
|
||||
}
|
||||
}
|
||||
|
||||
private static TimeZoneInfo ResolveSriLankaTimeZone()
|
||||
{
|
||||
try
|
||||
{
|
||||
return TimeZoneInfo.FindSystemTimeZoneById("Sri Lanka Standard Time"); // Windows ID
|
||||
}
|
||||
catch (TimeZoneNotFoundException)
|
||||
{
|
||||
try
|
||||
{
|
||||
return TimeZoneInfo.FindSystemTimeZoneById("Asia/Colombo"); // IANA ID (Linux/macOS)
|
||||
}
|
||||
catch (TimeZoneNotFoundException)
|
||||
{
|
||||
// Neither ID resolved (e.g. no tzdata on the host) -- Sri Lanka has kept a
|
||||
// fixed UTC+5:30 offset with no DST since 2006, so a custom fixed-offset
|
||||
// zone is a safe fallback rather than failing the whole job.
|
||||
return TimeZoneInfo.CreateCustomTimeZone(
|
||||
"Sri Lanka (fixed +05:30)", TimeSpan.FromMinutes(330), "Sri Lanka Time", "Sri Lanka Time");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delay-then-run, not run-then-delay: a fixed daily time trigger shouldn't fire an extra
|
||||
// run just because the app happened to restart. Internal + static so it's unit-testable
|
||||
// without spinning up the whole hosted service.
|
||||
|
||||
Reference in New Issue
Block a user