5d711bd13e
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>
456 lines
22 KiB
C#
456 lines
22 KiB
C#
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 System.Text.Json;
|
|
|
|
namespace Fitness_core_BE.Services
|
|
{
|
|
public class FacilityService : IFacilityService
|
|
{
|
|
private readonly IFacilityRepository _facilityRepository;
|
|
private readonly IClassService _classService;
|
|
private readonly IBookingService _bookingService;
|
|
private readonly IUnitOfWork _uow;
|
|
private readonly UserRoleHelper _userRoleHelper;
|
|
|
|
public FacilityService(
|
|
IFacilityRepository facilityRepository,
|
|
IClassService classService,
|
|
IBookingService bookingService,
|
|
IUnitOfWork uow,
|
|
UserRoleHelper userRoleHelper)
|
|
{
|
|
_facilityRepository = facilityRepository;
|
|
_classService = classService;
|
|
_bookingService = bookingService;
|
|
_uow = uow;
|
|
_userRoleHelper = userRoleHelper;
|
|
}
|
|
|
|
public async Task<object> GetAllFacilities(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 facilities = await _facilityRepository.GetAllFacilities(clientId, includeInactive);
|
|
|
|
var devicesByFacility = await _facilityRepository.GetDevicesByFacilityIdsAsync(facilities.Select(f => f.FacilityId).ToList());
|
|
|
|
return facilities.Select(f => MapToDto(f, devicesByFacility.GetValueOrDefault(f.FacilityId))).ToList();
|
|
}
|
|
|
|
public async Task<object> CreateFacility(object payload, HttpContext httpContext)
|
|
{
|
|
await _uow.BeginAsync();
|
|
try
|
|
{
|
|
var data = DeserializePayload.Deserialize(payload);
|
|
if (!data.ContainsKey("name")) throw new KeyNotFoundException("name is required");
|
|
|
|
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 (supportsClasses && defaultCapacity < 1)
|
|
throw new ArgumentException("defaultCapacity must be at least 1");
|
|
|
|
var numberOfUnits = data.ContainsKey("numberOfUnits") ? data["numberOfUnits"].GetInt32() : 1;
|
|
if (supportsBooking && numberOfUnits < 1)
|
|
throw new ArgumentException("numberOfUnits must be at least 1");
|
|
|
|
var pricePerHour = data.ContainsKey("pricePerHour") ? data["pricePerHour"].GetDecimal() : 0m;
|
|
if (pricePerHour < 0)
|
|
throw new ArgumentException("pricePerHour cannot be negative");
|
|
|
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
|
|
|
var facility = new Facility
|
|
{
|
|
FKClientId = clientId,
|
|
Name = data["name"].GetString() ?? "",
|
|
SupportsClasses = supportsClasses,
|
|
SupportsBooking = supportsBooking,
|
|
Description = data.ContainsKey("description") ? data["description"].GetString() : null,
|
|
DefaultCapacity = defaultCapacity,
|
|
NumberOfUnits = numberOfUnits,
|
|
PricePerHour = pricePerHour
|
|
};
|
|
|
|
var result = await _facilityRepository.CreateFacility(facility);
|
|
await _uow.CommitAsync();
|
|
|
|
return MapToDto(result, null);
|
|
}
|
|
catch
|
|
{
|
|
await _uow.RollbackAsync();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<object> UpdateFacility(object payload, HttpContext httpContext)
|
|
{
|
|
await _uow.BeginAsync();
|
|
try
|
|
{
|
|
var data = DeserializePayload.Deserialize(payload);
|
|
if (!data.ContainsKey("facilityId")) throw new KeyNotFoundException("facilityId is required");
|
|
|
|
var facilityId = Guid.Parse(data["facilityId"].GetString()!);
|
|
var existing = await _facilityRepository.GetFacilityById(facilityId)
|
|
?? throw new KeyNotFoundException("Facility not found");
|
|
|
|
if (data.ContainsKey("name")) existing.Name = data["name"].GetString() ?? existing.Name;
|
|
|
|
// 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"))
|
|
{
|
|
var capacity = data["defaultCapacity"].GetInt32();
|
|
if (capacity < 1) throw new ArgumentException("defaultCapacity must be at least 1");
|
|
existing.DefaultCapacity = capacity;
|
|
}
|
|
if (data.ContainsKey("numberOfUnits"))
|
|
{
|
|
var units = data["numberOfUnits"].GetInt32();
|
|
if (units < 1) throw new ArgumentException("numberOfUnits must be at least 1");
|
|
existing.NumberOfUnits = units;
|
|
}
|
|
if (data.ContainsKey("pricePerHour"))
|
|
{
|
|
var price = data["pricePerHour"].GetDecimal();
|
|
if (price < 0) throw new ArgumentException("pricePerHour cannot be negative");
|
|
existing.PricePerHour = price;
|
|
}
|
|
if (data.ContainsKey("isActive")) existing.IsActive = data["isActive"].GetBoolean();
|
|
|
|
var result = await _facilityRepository.UpdateFacility(existing);
|
|
await _uow.CommitAsync();
|
|
|
|
var devicesByFacility = await _facilityRepository.GetDevicesByFacilityIdsAsync(new List<Guid> { result.FacilityId });
|
|
return MapToDto(result, devicesByFacility.GetValueOrDefault(result.FacilityId));
|
|
}
|
|
catch
|
|
{
|
|
await _uow.RollbackAsync();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<object> SetFacilityStatus(object payload, HttpContext httpContext)
|
|
{
|
|
await _uow.BeginAsync();
|
|
try
|
|
{
|
|
var data = DeserializePayload.Deserialize(payload);
|
|
var facilityId = Guid.Parse(data["facilityId"].GetString()!);
|
|
var isActive = data["isActive"].GetBoolean();
|
|
|
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
|
var facility = await _facilityRepository.SetFacilityActive(facilityId, clientId, isActive)
|
|
?? throw new KeyNotFoundException("Facility not found");
|
|
|
|
await _uow.CommitAsync();
|
|
return MapToDto(facility, null);
|
|
}
|
|
catch
|
|
{
|
|
await _uow.RollbackAsync();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<object> CreateFacilitySession(object payload, HttpContext httpContext)
|
|
{
|
|
await _uow.BeginAsync();
|
|
try
|
|
{
|
|
var data = DeserializePayload.Deserialize(payload);
|
|
if (!data.ContainsKey("facilityId")) throw new KeyNotFoundException("facilityId is required");
|
|
if (!data.ContainsKey("startTime")) throw new KeyNotFoundException("startTime is required");
|
|
if (!data.ContainsKey("endTime")) throw new KeyNotFoundException("endTime is required");
|
|
|
|
var facilityId = Guid.Parse(data["facilityId"].GetString()!);
|
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
|
|
|
var facility = await _facilityRepository.GetFacilityById(facilityId);
|
|
if (facility == null || facility.FKClientId != clientId)
|
|
throw new KeyNotFoundException("Facility not found");
|
|
|
|
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();
|
|
if (endTime <= startTime)
|
|
throw new ArgumentException("endTime must be after startTime");
|
|
|
|
int? capacity = data.ContainsKey("capacity") && data["capacity"].ValueKind != JsonValueKind.Null
|
|
? data["capacity"].GetInt32()
|
|
: null;
|
|
if (capacity.HasValue && capacity.Value < 1)
|
|
throw new ArgumentException("capacity must be at least 1 when set");
|
|
|
|
var session = new FacilitySession
|
|
{
|
|
FKFacilityId = facilityId,
|
|
FKClientId = clientId,
|
|
StartTime = startTime,
|
|
EndTime = endTime,
|
|
Capacity = capacity
|
|
};
|
|
|
|
var result = await _facilityRepository.CreateFacilitySession(session);
|
|
await _uow.CommitAsync();
|
|
|
|
return MapToDto(result, facility.Name, 0);
|
|
}
|
|
catch
|
|
{
|
|
await _uow.RollbackAsync();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<object> GetSessions(object payload, HttpContext httpContext)
|
|
{
|
|
var data = DeserializePayload.Deserialize(payload);
|
|
|
|
Guid? facilityId = data.ContainsKey("facilityId") && data["facilityId"].ValueKind != JsonValueKind.Null
|
|
? Guid.Parse(data["facilityId"].GetString()!)
|
|
: null;
|
|
|
|
var fromUtc = data.ContainsKey("fromDate") && data["fromDate"].ValueKind != JsonValueKind.Null
|
|
? DateTime.Parse(data["fromDate"].GetString()!).ToUniversalTime()
|
|
: DateTime.UtcNow.Date;
|
|
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));
|
|
|
|
// 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)
|
|
{
|
|
var bookedCount = await _facilityRepository.GetActiveBookingCount(session.FacilitySessionId);
|
|
result.Add(MapToDto(session, session.Facility?.Name, bookedCount));
|
|
}
|
|
|
|
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();
|
|
try
|
|
{
|
|
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.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);
|
|
}
|
|
catch
|
|
{
|
|
await _uow.RollbackAsync();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
// --- Device binding (absorbed from PlaceService.AssignDevice, unchanged logic) ---
|
|
|
|
public async Task<object> AssignDevice(object payload, HttpContext httpContext)
|
|
{
|
|
await _uow.BeginAsync();
|
|
try
|
|
{
|
|
var data = DeserializePayload.Deserialize(payload);
|
|
if (!data.ContainsKey("facilityId")) throw new KeyNotFoundException("facilityId is required");
|
|
|
|
var facilityId = Guid.Parse(data["facilityId"].GetString()!);
|
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
|
|
|
var facility = await _facilityRepository.GetFacilityById(facilityId);
|
|
if (facility == null || facility.FKClientId != clientId)
|
|
throw new KeyNotFoundException("Facility not found");
|
|
|
|
var hasDeviceId = data.ContainsKey("deviceId") && data["deviceId"].ValueKind != JsonValueKind.Null;
|
|
if (!hasDeviceId)
|
|
{
|
|
await _facilityRepository.UnassignDeviceAsync(facilityId);
|
|
}
|
|
else
|
|
{
|
|
var deviceId = Guid.Parse(data["deviceId"].GetString()!);
|
|
var device = await _facilityRepository.GetDeviceByIdForClientAsync(deviceId, clientId)
|
|
?? throw new KeyNotFoundException("Device not found");
|
|
|
|
if (await _facilityRepository.IsDeviceAssignedToAnotherFacilityAsync(deviceId, facilityId))
|
|
throw new ArgumentException("This device is already assigned to another facility.");
|
|
|
|
await _facilityRepository.AssignDeviceAsync(facilityId, deviceId);
|
|
}
|
|
|
|
await _uow.CommitAsync();
|
|
|
|
var devicesByFacility = await _facilityRepository.GetDevicesByFacilityIdsAsync(new List<Guid> { facilityId });
|
|
return MapToDto(facility, devicesByFacility.GetValueOrDefault(facilityId));
|
|
}
|
|
catch
|
|
{
|
|
await _uow.RollbackAsync();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
// --- Availability (new) ---
|
|
|
|
public async Task<object> GetAvailability(object payload, HttpContext httpContext)
|
|
{
|
|
var data = DeserializePayload.Deserialize(payload);
|
|
if (!data.ContainsKey("startTime")) throw new KeyNotFoundException("startTime is required");
|
|
if (!data.ContainsKey("durationMinutes")) throw new KeyNotFoundException("durationMinutes is required");
|
|
|
|
var startTime = DateTime.Parse(data["startTime"].GetString()!).ToUniversalTime();
|
|
var durationMinutes = data["durationMinutes"].GetInt32();
|
|
if (durationMinutes < 1) throw new ArgumentException("durationMinutes must be at least 1");
|
|
var endTime = startTime.AddMinutes(durationMinutes);
|
|
|
|
Guid? facilityId = data.ContainsKey("facilityId") && data["facilityId"].ValueKind != JsonValueKind.Null
|
|
? Guid.Parse(data["facilityId"].GetString()!)
|
|
: null;
|
|
|
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
|
var facilities = facilityId.HasValue
|
|
? new List<Facility> { await _facilityRepository.GetFacilityById(facilityId.Value) is { } f && f.FKClientId == clientId ? f : throw new KeyNotFoundException("Facility not found") }
|
|
: await _facilityRepository.GetAllFacilities(clientId, includeInactive: false);
|
|
|
|
var result = new List<FacilityAvailabilityDto>();
|
|
foreach (var facility in facilities)
|
|
{
|
|
// True interval overlap, not "session starts inside the window" -- a class that
|
|
// started earlier and is still running must still count.
|
|
var overlappingSessions = await _facilityRepository.GetOverlappingActiveSessionsAsync(facility.FacilityId, startTime, endTime);
|
|
|
|
int? bookingTotal = null, bookingFree = null, classTotal = null, classFree = null;
|
|
bool classIsUnlimited = false;
|
|
|
|
if (facility.SupportsBooking)
|
|
{
|
|
var overlapping = await _facilityRepository.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;
|
|
}
|
|
|
|
private static FacilityDto MapToDto(Facility facility, AccessDevice? device) => new()
|
|
{
|
|
FacilityId = facility.FacilityId,
|
|
Name = facility.Name,
|
|
SupportsClasses = facility.SupportsClasses,
|
|
SupportsBooking = facility.SupportsBooking,
|
|
Description = facility.Description,
|
|
DefaultCapacity = facility.DefaultCapacity,
|
|
NumberOfUnits = facility.NumberOfUnits,
|
|
PricePerHour = facility.PricePerHour,
|
|
IsActive = facility.IsActive,
|
|
CreatedAt = facility.CreatedAt,
|
|
DeviceId = device?.DeviceId,
|
|
DeviceName = device?.DeviceName
|
|
};
|
|
|
|
private static FacilitySessionDto MapToDto(FacilitySession session, string? facilityName = null, int bookedCount = 0) => new()
|
|
{
|
|
FacilitySessionId = session.FacilitySessionId,
|
|
FacilityId = session.FKFacilityId,
|
|
FacilityName = facilityName ?? session.Facility?.Name,
|
|
StartTime = session.StartTime,
|
|
EndTime = session.EndTime,
|
|
Capacity = session.Capacity ?? session.Facility?.DefaultCapacity ?? 1,
|
|
IsUnlimitedCapacity = session.IsUnlimitedCapacity,
|
|
BookedCount = bookedCount,
|
|
IsCancelled = session.IsCancelled,
|
|
FacilityClassId = session.FKFacilityClassId,
|
|
FacilityClassName = session.FacilityClass?.Name,
|
|
NotHeldReason = session.NotHeldReason,
|
|
NotHeldAt = session.NotHeldAt
|
|
};
|
|
}
|
|
}
|