Refactor CustomerPlanService to use FacilityRepository, remove PlaceService, and introduce RfidTagService

- Updated CustomerPlanService to replace references to PlaceRepo with FacilityRepository for managing customer plans and associated facilities.
- Removed PlaceService and its related methods, consolidating functionality into FacilityService.
- Added RfidTagService for managing RFID tags, including methods for creating, updating, and setting statuses of tags.
- Updated TenantDbContext to include new DbSet for RfidTags and CustomerPlanFacilities, and adjusted model configurations accordingly.
- Enhanced FacilityService to support new booking modes and validation for facility creation and updates.
This commit is contained in:
2026-09-29 13:02:52 +05:30
parent bffc9b1686
commit 7d8a8478ff
39 changed files with 1596 additions and 678 deletions
+409 -33
View File
@@ -5,30 +5,28 @@ using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
using System.Security.Cryptography;
using System.Text.Json;
namespace Fitness_core_BE.Services
{
public class BookingService : IBookingService
public class BookingService(
IBookingRepository bookingRepository,
IFacilityRepository facilityRepository,
IRfidTagRepository rfidTagRepository,
ICustomerSubscriptionRepository customerSubscriptionRepository,
IUnitOfWork uow,
UserRoleHelper userRoleHelper) : IBookingService
{
private readonly IBookingRepository _bookingRepository;
private readonly IFacilityRepository _facilityRepository;
private readonly ICustomerSubscriptionRepository _customerSubscriptionRepository;
private readonly IUnitOfWork _uow;
private readonly UserRoleHelper _userRoleHelper;
private readonly IBookingRepository _bookingRepository = bookingRepository;
private readonly IFacilityRepository _facilityRepository = facilityRepository;
private readonly IRfidTagRepository _rfidTagRepository = rfidTagRepository;
private readonly ICustomerSubscriptionRepository _customerSubscriptionRepository = customerSubscriptionRepository;
private readonly IUnitOfWork _uow = uow;
private readonly UserRoleHelper _userRoleHelper = userRoleHelper;
// ---------- Scheduled-mode creation (existing behavior, now also stamping the
// denormalized FKFacilityId/StartTime/EndTime every Booking carries) ----------
public BookingService(
IBookingRepository bookingRepository,
IFacilityRepository facilityRepository,
ICustomerSubscriptionRepository customerSubscriptionRepository,
IUnitOfWork uow,
UserRoleHelper userRoleHelper)
{
_bookingRepository = bookingRepository;
_facilityRepository = facilityRepository;
_customerSubscriptionRepository = customerSubscriptionRepository;
_uow = uow;
_userRoleHelper = userRoleHelper;
}
public async Task<object> CreateMemberBooking(object payload, HttpContext httpContext)
{
@@ -78,6 +76,10 @@ namespace Fitness_core_BE.Services
var booking = new Booking
{
FKFacilitySessionId = facilitySessionId,
FKFacilityId = session.FKFacilityId,
Facility = session.Facility,
StartTime = session.StartTime,
EndTime = session.EndTime,
FKClientId = session.FKClientId,
IsGuest = false,
FKCustomerId = cusId,
@@ -93,7 +95,7 @@ namespace Fitness_core_BE.Services
await _uow.CommitAsync();
return MapToDto(booking, session, includeOtp: false, BuildShareUrl(httpContext, booking.BookingId));
return MapToDto(booking, includeOtp: false, BuildShareUrl(httpContext, booking.BookingId));
}
catch
{
@@ -127,6 +129,10 @@ namespace Fitness_core_BE.Services
var booking = new Booking
{
FKFacilitySessionId = facilitySessionId,
FKFacilityId = session.FKFacilityId,
Facility = session.Facility,
StartTime = session.StartTime,
EndTime = session.EndTime,
FKClientId = session.FKClientId,
IsGuest = true,
GuestName = guestName,
@@ -142,7 +148,7 @@ namespace Fitness_core_BE.Services
await _uow.CommitAsync();
// The only time the OTP is ever returned -- this response IS the guest's receipt.
return MapToDto(booking, session, includeOtp: true, BuildShareUrl(httpContext, booking.BookingId));
return MapToDto(booking, includeOtp: true, BuildShareUrl(httpContext, booking.BookingId));
}
catch
{
@@ -151,6 +157,123 @@ namespace Fitness_core_BE.Services
}
}
// ---------- AdHoc-mode creation (new) ----------
public async Task<object> CreateMemberAdhocBooking(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var (facility, startTime, endTime, partySize) = await ValidateAdhocRequestAsync(payload);
var cusId = await _userRoleHelper.GetCusId(DataExtractor.GetUserIdFromContext(httpContext));
var booking = new Booking
{
FKFacilitySessionId = null,
FKFacilityId = facility.FacilityId,
Facility = facility,
StartTime = startTime,
EndTime = endTime,
PartySize = partySize,
FKClientId = facility.FKClientId,
IsGuest = false,
FKCustomerId = cusId,
// Simplifying assumption mirroring the existing Scheduled member-booking
// path: membership covers facility use, no separate per-booking charge.
PaymentStatus = "NotRequired",
Status = "Confirmed"
};
await _bookingRepository.CreateBooking(booking);
await _uow.CommitAsync();
return MapToDto(booking, includeOtp: false, BuildShareUrl(httpContext, booking.BookingId));
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> CreateGuestAdhocBooking(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var (facility, startTime, endTime, partySize) = await ValidateAdhocRequestAsync(payload);
var data = DeserializePayload.Deserialize(payload);
var guestName = data["guestName"].GetString() ?? throw new ArgumentException("guestName is required");
var guestPhone = data["guestPhone"].GetString() ?? throw new ArgumentException("guestPhone is required");
var durationHours = (decimal)(endTime - startTime).TotalHours;
var booking = new Booking
{
FKFacilitySessionId = null,
FKFacilityId = facility.FacilityId,
Facility = facility,
StartTime = startTime,
EndTime = endTime,
PartySize = partySize,
FKClientId = facility.FKClientId,
IsGuest = true,
GuestName = guestName,
GuestPhone = guestPhone,
OtpCode = GenerateOtp(),
AmountCharged = Math.Round(facility.PricePerHour * durationHours, 2),
PaymentStatus = "Unpaid",
Status = "Confirmed"
};
await _bookingRepository.CreateBooking(booking);
await _uow.CommitAsync();
return MapToDto(booking, includeOtp: true, BuildShareUrl(httpContext, booking.BookingId));
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
/// <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)> ValidateAdhocRequestAsync(object payload)
{
var data = DeserializePayload.Deserialize(payload);
var facilityId = Guid.Parse(data["facilityId"].GetString()!);
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);
int? partySize = data.ContainsKey("partySize") && data["partySize"].ValueKind != JsonValueKind.Null
? data["partySize"].GetInt32()
: null;
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.IsActive)
throw new ArgumentException("This facility is not active");
await _facilityRepository.LockFacilityForUpdateAsync(facilityId);
var overlapping = await _facilityRepository.GetOverlappingAdhocBookingCountAsync(facilityId, startTime, endTime);
if (overlapping >= facility.NumberOfUnits)
throw new ArgumentException("No units are free for the requested time");
return (facility, startTime, endTime, partySize);
}
// ---------- Payment / check-in / lifecycle ----------
public async Task<object> MarkGuestBookingPaid(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
@@ -169,7 +292,7 @@ namespace Fitness_core_BE.Services
booking.AmountCharged = amountCharged;
await _uow.CommitAsync();
return MapToDto(booking, booking.FacilitySession, includeOtp: false);
return MapToDto(booking, includeOtp: false);
}
catch
{
@@ -185,19 +308,49 @@ namespace Fitness_core_BE.Services
{
var data = DeserializePayload.Deserialize(payload);
var otpCode = data["otpCode"].GetString() ?? throw new ArgumentException("otpCode is required");
Guid? rfidTagId = data.ContainsKey("rfidTagId") && data["rfidTagId"].ValueKind != JsonValueKind.Null
? Guid.Parse(data["rfidTagId"].GetString()!)
: null;
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
// Reconcile everything the passage of time has made stale for this client BEFORE
// processing the new check-in, instead of a background job -- see
// Booking.Status doc comment. If the booking being checked in is itself already
// past its window, this sweep flips it to NoShow first, so the OTP lookup below
// (Status == "Confirmed" only) correctly refuses it.
await SweepOverdueBookingsAsync(clientId, DateTime.UtcNow);
var booking = await _bookingRepository.GetActiveBookingByOtp(otpCode)
?? throw new KeyNotFoundException("No confirmed booking found for this code");
if (booking.FKClientId != clientId)
throw new KeyNotFoundException("No confirmed booking found for this code");
if (booking.OtpVerifiedAt.HasValue)
throw new ArgumentException("This booking has already been checked in");
if (rfidTagId.HasValue)
{
var tag = await _rfidTagRepository.GetTagById(rfidTagId.Value, clientId)
?? throw new KeyNotFoundException("RFID tag not found");
if (tag.FKClientId != booking.FKClientId)
throw new KeyNotFoundException("RFID tag not found");
var now = DateTime.UtcNow;
if (!await _bookingRepository.TryClaimRfidTagAsync(rfidTagId.Value, now))
throw new ArgumentException("This tag is no longer available -- pick another one.");
booking.FKRfidTagId = rfidTagId.Value;
booking.RfidAssignedAt = now;
booking.RfidTag = tag; // for the immediate response's RfidTagLabel only
}
booking.OtpVerifiedAt = DateTime.UtcNow;
booking.CheckedInAt = DateTime.UtcNow;
booking.Status = "CheckedIn";
await _uow.CommitAsync();
return MapToDto(booking, booking.FacilitySession, includeOtp: false);
return MapToDto(booking, includeOtp: false);
}
catch
{
@@ -206,6 +359,31 @@ namespace Fitness_core_BE.Services
}
}
/// <summary>Lazily reconciles everything a background job would otherwise have swept:
/// Confirmed bookings whose window passed with no check-in become NoShow; CheckedIn
/// bookings whose window passed become Completed and release their tag. Runs at the top
/// of every check-in, across every facility for this client (not scoped to whichever
/// facility is being checked into right now), so the whole roster stays accurate as long
/// as check-ins keep happening anywhere in the club.</summary>
private async Task SweepOverdueBookingsAsync(Guid clientId, DateTime now)
{
await _bookingRepository.MarkOverdueConfirmedAsNoShowAsync(clientId, now);
var overdueCheckedIn = await _bookingRepository.GetOverdueCheckedInBookingsAsync(clientId, now);
foreach (var booking in overdueCheckedIn)
{
booking.Status = "Completed";
booking.CheckedOutAt = now;
if (booking.RfidTag != null && booking.RfidReturnedAt == null)
{
booking.RfidTag.Status = "Available";
booking.RfidTag.UpdatedAt = now;
booking.RfidReturnedAt = now;
}
}
// Caller's _uow.CommitAsync() persists these tracked changes alongside its own.
}
public async Task<object> CancelBooking(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
@@ -216,8 +394,8 @@ namespace Fitness_core_BE.Services
var booking = await _bookingRepository.GetBookingById(bookingId)
?? throw new KeyNotFoundException("Booking not found");
if (booking.Status == "Cancelled")
throw new ArgumentException("This booking is already cancelled");
if (booking.Status is "Cancelled" or "NoShow" or "Completed")
throw new ArgumentException($"This booking is already {booking.Status} and can't be cancelled");
if (booking.Status == "CheckedIn")
throw new ArgumentException("This booking has already been checked in and can't be cancelled");
@@ -235,7 +413,7 @@ namespace Fitness_core_BE.Services
}
await _uow.CommitAsync();
return MapToDto(booking, booking.FacilitySession, includeOtp: false);
return MapToDto(booking, includeOtp: false);
}
catch
{
@@ -244,11 +422,180 @@ namespace Fitness_core_BE.Services
}
}
public async Task<object> ReassignBooking(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
var bookingId = Guid.Parse(data["bookingId"].GetString()!);
var booking = await _bookingRepository.GetBookingById(bookingId)
?? throw new KeyNotFoundException("Booking not found");
if (!booking.IsGuest)
throw new ArgumentException("Only guest bookings can be reassigned");
if (booking.Status != "Confirmed")
throw new ArgumentException("Only a booking that hasn't been checked in yet can be reassigned");
if (data.ContainsKey("guestName")) booking.GuestName = data["guestName"].GetString() ?? booking.GuestName;
if (data.ContainsKey("guestPhone")) booking.GuestPhone = data["guestPhone"].GetString() ?? booking.GuestPhone;
await _uow.CommitAsync();
return MapToDto(booking, includeOtp: false);
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> RescheduleBooking(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
var bookingId = Guid.Parse(data["bookingId"].GetString()!);
var booking = await _bookingRepository.GetBookingById(bookingId)
?? throw new KeyNotFoundException("Booking not found");
if (booking.Status != "Confirmed")
throw new ArgumentException("Only a booking that hasn't been checked in yet can be rescheduled");
if (booking.FKFacilitySessionId == null)
{
// AdHoc: 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");
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);
await _facilityRepository.LockFacilityForUpdateAsync(booking.FKFacilityId);
var overlapping = await _facilityRepository.GetOverlappingAdhocBookingCountAsync(
booking.FKFacilityId, startTime, endTime, excludeBookingId: booking.BookingId);
var facility = booking.Facility ?? await _facilityRepository.GetFacilityById(booking.FKFacilityId)
?? throw new KeyNotFoundException("Facility not found");
if (overlapping >= facility.NumberOfUnits)
throw new ArgumentException("No units are free for the requested time");
if (booking.PaymentStatus != "Paid" && booking.IsGuest)
{
var durationHours = (decimal)(endTime - startTime).TotalHours;
booking.AmountCharged = Math.Round(facility.PricePerHour * durationHours, 2);
}
booking.StartTime = startTime;
booking.EndTime = endTime;
}
else
{
// Scheduled: move to a different pre-created session on the same facility.
if (!data.ContainsKey("facilitySessionId"))
throw new ArgumentException("facilitySessionId is required to reschedule a scheduled booking");
var newSessionId = Guid.Parse(data["facilitySessionId"].GetString()!);
var newSession = await _facilityRepository.GetFacilitySessionById(newSessionId)
?? throw new KeyNotFoundException("Facility session not found");
if (newSession.FKFacilityId != booking.FKFacilityId)
throw new ArgumentException("The new session must be on the same facility");
if (newSession.IsCancelled)
throw new ArgumentException("This session has been cancelled");
if (newSession.StartTime <= DateTime.UtcNow)
throw new ArgumentException("This session has already started");
var capacity = newSession.Capacity ?? newSession.Facility?.DefaultCapacity ?? 1;
var bookedCount = await _facilityRepository.GetActiveBookingCount(newSessionId);
// Excluding this booking's own current session membership naturally falls out
// here since bookedCount only counts rows already on newSessionId, which this
// booking isn't yet.
if (bookedCount >= capacity)
throw new ArgumentException("This session is fully booked");
booking.FKFacilitySessionId = newSessionId;
booking.StartTime = newSession.StartTime;
booking.EndTime = newSession.EndTime;
}
await _uow.CommitAsync();
return MapToDto(booking, includeOtp: false);
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> MarkNoShow(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
var bookingId = Guid.Parse(data["bookingId"].GetString()!);
var booking = await _bookingRepository.GetBookingById(bookingId)
?? throw new KeyNotFoundException("Booking not found");
if (booking.Status != "Confirmed")
throw new ArgumentException("Only a Confirmed booking can be marked no-show");
booking.Status = "NoShow";
await _uow.CommitAsync();
return MapToDto(booking, includeOtp: false);
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> CompleteBooking(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
var bookingId = Guid.Parse(data["bookingId"].GetString()!);
var booking = await _bookingRepository.GetBookingById(bookingId)
?? throw new KeyNotFoundException("Booking not found");
if (booking.Status != "CheckedIn")
throw new ArgumentException("Only a CheckedIn booking can be completed");
var now = DateTime.UtcNow;
booking.Status = "Completed";
booking.CheckedOutAt = now;
if (booking.RfidTag != null && booking.RfidReturnedAt == null)
{
booking.RfidTag.Status = "Available";
booking.RfidTag.UpdatedAt = now;
booking.RfidReturnedAt = now;
}
await _uow.CommitAsync();
return MapToDto(booking, includeOtp: false);
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
// ---------- Reads ----------
public async Task<object> GetMyBookings(object payload, HttpContext httpContext)
{
var cusId = await _userRoleHelper.GetCusId(DataExtractor.GetUserIdFromContext(httpContext));
var bookings = await _bookingRepository.GetBookingsForCustomer(cusId);
return bookings.Select(b => MapToDto(b, b.FacilitySession, includeOtp: false)).ToList();
return bookings.Select(b => MapToDto(b, includeOtp: false)).ToList();
}
public async Task<object> GetSessionBookings(object payload, HttpContext httpContext)
@@ -259,7 +606,28 @@ namespace Fitness_core_BE.Services
var bookings = await _bookingRepository.GetBookingsForSession(facilitySessionId);
// Staff view -- never expose guest OTPs here either; the door check-in flow reads
// the OTP the guest already has, it doesn't need staff to look it up beforehand.
return bookings.Select(b => MapToDto(b, b.FacilitySession, includeOtp: false)).ToList();
return bookings.Select(b => MapToDto(b, includeOtp: false)).ToList();
}
public async Task<object> GetBookings(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;
Guid? facilitySessionId = data.ContainsKey("facilitySessionId") && data["facilitySessionId"].ValueKind != JsonValueKind.Null
? Guid.Parse(data["facilitySessionId"].GetString()!) : null;
DateTime? fromUtc = data.ContainsKey("fromDate") && data["fromDate"].ValueKind != JsonValueKind.Null
? DateTime.Parse(data["fromDate"].GetString()!).ToUniversalTime() : null;
DateTime? toUtc = data.ContainsKey("toDate") && data["toDate"].ValueKind != JsonValueKind.Null
? DateTime.Parse(data["toDate"].GetString()!).ToUniversalTime() : null;
string? status = data.ContainsKey("status") && data["status"].ValueKind != JsonValueKind.Null
? data["status"].GetString() : null;
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
var bookings = await _bookingRepository.GetBookings(clientId, facilityId, facilitySessionId, fromUtc, toUtc, status);
return bookings.Select(b => MapToDto(b, includeOtp: false)).ToList();
}
public async Task<object?> GetBookingView(object payload)
@@ -273,7 +641,7 @@ namespace Fitness_core_BE.Services
// The share link itself is the credential (an unguessable GUID) -- the OTP is only
// withheld once it's already been used, same rule as every other read path.
return MapToDto(booking, booking.FacilitySession, includeOtp: !booking.OtpVerifiedAt.HasValue);
return MapToDto(booking, includeOtp: !booking.OtpVerifiedAt.HasValue);
}
private static string GenerateOtp() => RandomNumberGenerator.GetInt32(100000, 1000000).ToString();
@@ -284,24 +652,32 @@ namespace Fitness_core_BE.Services
private static string BuildShareUrl(HttpContext httpContext, Guid bookingId)
=> $"{httpContext.Request.Scheme}://{httpContext.Request.Host}/api/public/bookings/{bookingId}/view";
private static BookingDto MapToDto(Booking booking, FacilitySession? session, bool includeOtp, string? shareUrl = null) => new()
private static BookingDto MapToDto(Booking booking, bool includeOtp, string? shareUrl = null) => new()
{
BookingId = booking.BookingId,
FacilitySessionId = booking.FKFacilitySessionId,
FacilityName = session?.Facility?.Name,
SessionStartTime = session?.StartTime,
SessionEndTime = session?.EndTime,
FacilityId = booking.FKFacilityId,
FacilityName = booking.Facility?.Name,
BookingMode = booking.Facility?.BookingMode ?? (booking.FKFacilitySessionId.HasValue ? "Scheduled" : "AdHoc"),
StartTime = booking.StartTime,
EndTime = booking.EndTime,
PartySize = booking.PartySize,
IsGuest = booking.IsGuest,
CustomerId = booking.FKCustomerId,
GuestName = booking.GuestName,
GuestPhone = booking.GuestPhone,
OtpCode = includeOtp ? booking.OtpCode : null,
OtpVerified = booking.OtpVerifiedAt.HasValue,
RfidTagId = booking.FKRfidTagId,
RfidTagLabel = booking.RfidTag?.Label ?? booking.RfidTag?.TagCode,
RfidAssignedAt = booking.RfidAssignedAt,
RfidReturnedAt = booking.RfidReturnedAt,
PaymentStatus = booking.PaymentStatus,
AmountCharged = booking.AmountCharged,
Status = booking.Status,
BookedAt = booking.BookedAt,
CheckedInAt = booking.CheckedInAt,
CheckedOutAt = booking.CheckedOutAt,
ShareUrl = shareUrl
};
}