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:
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user