From 7d8a8478ff02538d36833954dd6587aa65224962 Mon Sep 17 00:00:00 2001 From: Haritha Randunu Date: Tue, 29 Sep 2026 13:02:52 +0530 Subject: [PATCH 1/3] 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. --- Controllers/BookingController.cs | 84 ++++- Controllers/FacilityController.cs | 51 ++- Controllers/PlaceController.cs | 71 ---- Controllers/RfidTagController.cs | 54 +++ DTOs/Booking/BookingDto.cs | 24 +- DTOs/Booking/BookingRequests.cs | 61 ++++ DTOs/Facility/FacilityDtos.cs | 23 +- DTOs/Facility/FacilityRequests.cs | 34 +- DTOs/Place/PlaceDtos.cs | 15 - DTOs/Place/PlaceRequests.cs | 32 -- DTOs/RfidTag/RfidTagDtos.cs | 14 + DTOs/RfidTag/RfidTagRequests.cs | 25 ++ Helpers/BookingViewHtml.cs | 4 +- Interfaces/IBookingRepository.cs | 22 ++ Interfaces/IFacilityRepository.cs | 26 ++ Interfaces/IPlaceRepo.cs | 25 -- Interfaces/IRfidTagRepository.cs | 18 + Interfaces/Services/IBookingService.cs | 30 +- Interfaces/Services/IFacilityService.cs | 5 + Interfaces/Services/IPlaceService.cs | 13 - Interfaces/Services/IRfidTagService.cs | 12 + Models/AccessDevice.cs | 14 +- Models/Booking.cs | 58 +++- Models/CustomerPlanFacility.cs | 25 ++ Models/CustomerPlanPlace.cs | 22 -- Models/Facility.cs | 33 +- Models/Place.cs | 30 -- Models/RfidTag.cs | 35 ++ Program.cs | 4 +- Repos/BookingRepository.cs | 52 ++- Repos/FacilityRepository.cs | 120 ++++++- Repos/PlaceRepo.cs | 143 -------- Repos/RfidTagRepository.cs | 67 ++++ Services/BookingService.cs | 442 ++++++++++++++++++++++-- Services/CustomerPlanService.cs | 38 +- Services/FacilityService.cs | 184 ++++++++-- Services/PlaceService.cs | 175 ---------- Services/RfidTagService.cs | 133 +++++++ TenantDbContext.cs | 56 ++- 39 files changed, 1596 insertions(+), 678 deletions(-) delete mode 100644 Controllers/PlaceController.cs create mode 100644 Controllers/RfidTagController.cs delete mode 100644 DTOs/Place/PlaceDtos.cs delete mode 100644 DTOs/Place/PlaceRequests.cs create mode 100644 DTOs/RfidTag/RfidTagDtos.cs create mode 100644 DTOs/RfidTag/RfidTagRequests.cs delete mode 100644 Interfaces/IPlaceRepo.cs create mode 100644 Interfaces/IRfidTagRepository.cs delete mode 100644 Interfaces/Services/IPlaceService.cs create mode 100644 Interfaces/Services/IRfidTagService.cs create mode 100644 Models/CustomerPlanFacility.cs delete mode 100644 Models/CustomerPlanPlace.cs delete mode 100644 Models/Place.cs create mode 100644 Models/RfidTag.cs delete mode 100644 Repos/PlaceRepo.cs create mode 100644 Repos/RfidTagRepository.cs delete mode 100644 Services/PlaceService.cs create mode 100644 Services/RfidTagService.cs diff --git a/Controllers/BookingController.cs b/Controllers/BookingController.cs index 9e0de01..5923f31 100644 --- a/Controllers/BookingController.cs +++ b/Controllers/BookingController.cs @@ -32,6 +32,18 @@ namespace Fitness_core_BE.Controllers public async Task>> CreateMemberBooking([FromBody] CreateMemberBookingRequest request) => Response(await _bookingService.CreateMemberBooking(new { facilitySessionId = request.FacilitySessionId }, HttpContext)); + [CustomMiddleware] + [TenantResolution] + [HttpPost("api/app/bookings/adhoc")] + public async Task>> CreateMemberAdhocBooking([FromBody] CreateAdhocBookingRequest request) + => Response(await _bookingService.CreateMemberAdhocBooking(new + { + facilityId = request.FacilityId, + startTime = request.StartTime.ToString("o"), + durationMinutes = request.DurationMinutes, + partySize = request.PartySize + }, HttpContext)); + [CustomMiddleware] [TenantResolution] [HttpGet("api/app/bookings/mine")] @@ -56,6 +68,19 @@ namespace Fitness_core_BE.Controllers guestPhone = request.GuestPhone }, HttpContext)); + [AllowAnonymous] + [HttpPost("api/public/bookings/guest/adhoc")] + public async Task>> CreateGuestAdhocBooking([FromBody] CreateGuestAdhocBookingRequest request) + => Response(await _bookingService.CreateGuestAdhocBooking(new + { + facilityId = request.FacilityId, + startTime = request.StartTime.ToString("o"), + durationMinutes = request.DurationMinutes, + guestName = request.GuestName, + guestPhone = request.GuestPhone, + partySize = request.PartySize + }, HttpContext)); + // The actual "copy the link and share it" page -- BookingDto.ShareUrl points here. // Plain HTML, not the JSON ApiResponse envelope, since this is meant to be opened // directly in a browser (or a chat app's link preview), not consumed by a client SDK. @@ -72,6 +97,24 @@ namespace Fitness_core_BE.Controllers // ---------- Staff ---------- + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("bookings")] + [HttpGet("api/client/bookings")] + public async Task>> GetBookings( + [FromQuery] Guid? facilityId, [FromQuery] Guid? facilitySessionId, + [FromQuery] DateTime? fromDate, [FromQuery] DateTime? toDate, [FromQuery] string? status) + => Response(await _bookingService.GetBookings(new + { + facilityId, + facilitySessionId, + fromDate = fromDate?.ToString("o"), + toDate = toDate?.ToString("o"), + status + }, HttpContext)); + + // Kept as a thin backward-compat wrapper around the unified GetBookings above -- + // prefer GET api/client/bookings?facilitySessionId= going forward. [CustomMiddleware] [TenantResolution] [RequiresPermission("bookings")] @@ -84,7 +127,7 @@ namespace Fitness_core_BE.Controllers [RequiresPermission("bookings")] [HttpPost("api/client/bookings/check-in")] public async Task>> CheckInBooking([FromBody] CheckInBookingRequest request) - => Response(await _bookingService.CheckInBooking(new { otpCode = request.OtpCode }, HttpContext)); + => Response(await _bookingService.CheckInBooking(new { otpCode = request.OtpCode, rfidTagId = request.RfidTagId }, HttpContext)); [CustomMiddleware] [TenantResolution] @@ -99,5 +142,44 @@ namespace Fitness_core_BE.Controllers [HttpPost("api/client/bookings/{id:guid}/cancel")] public async Task>> CancelBooking(Guid id) => Response(await _bookingService.CancelBooking(new { bookingId = id }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("bookings")] + [HttpPut("api/client/bookings/{id:guid}/reassign")] + public async Task>> ReassignBooking(Guid id, [FromBody] ReassignBookingRequest request) + => Response(await _bookingService.ReassignBooking(new + { + bookingId = id, + guestName = request.GuestName, + guestPhone = request.GuestPhone + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("bookings")] + [HttpPut("api/client/bookings/{id:guid}/reschedule")] + public async Task>> RescheduleBooking(Guid id, [FromBody] RescheduleBookingRequest request) + => Response(await _bookingService.RescheduleBooking(new + { + bookingId = id, + startTime = request.StartTime?.ToString("o"), + durationMinutes = request.DurationMinutes, + facilitySessionId = request.FacilitySessionId + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("bookings")] + [HttpPost("api/client/bookings/{id:guid}/no-show")] + public async Task>> MarkNoShow(Guid id) + => Response(await _bookingService.MarkNoShow(new { bookingId = id }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("bookings")] + [HttpPost("api/client/bookings/{id:guid}/complete")] + public async Task>> CompleteBooking(Guid id) + => Response(await _bookingService.CompleteBooking(new { bookingId = id }, HttpContext)); } } diff --git a/Controllers/FacilityController.cs b/Controllers/FacilityController.cs index f723bbf..96d2493 100644 --- a/Controllers/FacilityController.cs +++ b/Controllers/FacilityController.cs @@ -8,18 +8,15 @@ using static Fitness_core_BE.Helpers.ApiResponseHelper; namespace Fitness_core_BE.Controllers { - // Staff/owner-facing facility + facility-session management. Gated on the "bookings" nav - // permission (NavItem seeded in CentralDbContext.OnModelCreating). + // Staff/owner-facing facility (bookable area + access zone -- merged from the former + // Facility/Place split) management. Reads stay on the "bookings" nav permission (the + // Bookings page's tabs need the list); mutations (create/edit/status/device-assign) are on + // "memberships" since facility setup now lives on the Memberships page. [ApiController] [Route("api/client/facilities")] - public class FacilityController : ControllerBase + public class FacilityController(IFacilityService facilityService) : ControllerBase { - private readonly IFacilityService _facilityService; - - public FacilityController(IFacilityService facilityService) - { - _facilityService = facilityService; - } + private readonly IFacilityService _facilityService = facilityService; [CustomMiddleware] [TenantResolution] @@ -30,28 +27,32 @@ namespace Fitness_core_BE.Controllers [CustomMiddleware] [TenantResolution] - [RequiresPermission("bookings")] + [RequiresPermission("memberships")] [HttpPost("")] public async Task>> CreateFacility([FromBody] CreateFacilityRequest request) => Response(await _facilityService.CreateFacility(new { name = request.Name, - facilityType = request.FacilityType, + bookingMode = request.BookingMode, description = request.Description, - defaultCapacity = request.DefaultCapacity + defaultCapacity = request.DefaultCapacity, + numberOfUnits = request.NumberOfUnits, + pricePerHour = request.PricePerHour }, HttpContext)); [CustomMiddleware] [TenantResolution] - [RequiresPermission("bookings")] + [RequiresPermission("memberships")] [HttpPut("{id:guid}")] public async Task>> UpdateFacility(Guid id, [FromBody] UpdateFacilityRequest request) { var payload = new Dictionary { ["facilityId"] = id }; if (request.Name != null) payload["name"] = request.Name; - if (request.FacilityType != null) payload["facilityType"] = request.FacilityType; + if (request.BookingMode != null) payload["bookingMode"] = request.BookingMode; if (request.Description != null) payload["description"] = request.Description; if (request.DefaultCapacity.HasValue) payload["defaultCapacity"] = request.DefaultCapacity.Value; + if (request.NumberOfUnits.HasValue) payload["numberOfUnits"] = request.NumberOfUnits.Value; + if (request.PricePerHour.HasValue) payload["pricePerHour"] = request.PricePerHour.Value; if (request.IsActive.HasValue) payload["isActive"] = request.IsActive.Value; return Response(await _facilityService.UpdateFacility(payload, HttpContext)); @@ -59,11 +60,18 @@ namespace Fitness_core_BE.Controllers [CustomMiddleware] [TenantResolution] - [RequiresPermission("bookings")] + [RequiresPermission("memberships")] [HttpPatch("{id:guid}/status")] public async Task>> SetFacilityStatus(Guid id, [FromBody] SetFacilityStatusRequest request) => Response(await _facilityService.SetFacilityStatus(new { facilityId = id, isActive = request.IsActive }, HttpContext)); + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("memberships")] + [HttpPut("{id:guid}/device")] + public async Task>> AssignDevice(Guid id, [FromBody] AssignFacilityDeviceRequest request) + => Response(await _facilityService.AssignDevice(new { facilityId = id, deviceId = request.DeviceId }, HttpContext)); + [CustomMiddleware] [TenantResolution] [RequiresPermission("bookings")] @@ -96,5 +104,18 @@ namespace Fitness_core_BE.Controllers [HttpDelete("sessions/{id:guid}")] public async Task>> CancelFacilitySession(Guid id) => Response(await _facilityService.CancelFacilitySession(new { facilitySessionId = id }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("bookings")] + [HttpGet("availability")] + public async Task>> GetAvailability( + [FromQuery] Guid? facilityId, [FromQuery] DateTime startTime, [FromQuery] int durationMinutes) + => Response(await _facilityService.GetAvailability(new + { + facilityId, + startTime = startTime.ToString("o"), + durationMinutes + }, HttpContext)); } } diff --git a/Controllers/PlaceController.cs b/Controllers/PlaceController.cs deleted file mode 100644 index 15f71ee..0000000 --- a/Controllers/PlaceController.cs +++ /dev/null @@ -1,71 +0,0 @@ -using Fitness_core_BE.DTOs.Place; -using Fitness_core_BE.Helpers; -using Fitness_core_BE.Interfaces.Services; -using Fitness_core_BE.Models.IOs; -using Microsoft.AspNetCore.Mvc; -using static Fitness_core_BE.Helpers.JwtMiddleware; -using static Fitness_core_BE.Helpers.ApiResponseHelper; - -namespace Fitness_core_BE.Controllers -{ - // Staff/owner-facing management of physical access zones (Places) and which fingerprint - // terminal (AccessDevice) sits in each. Gated on "memberships" since Places exist to - // scope membership-plan access, not bookings. - [ApiController] - [Route("api/client/places")] - public class PlaceController : ControllerBase - { - private readonly IPlaceService _placeService; - - public PlaceController(IPlaceService placeService) - { - _placeService = placeService; - } - - [CustomMiddleware] - [TenantResolution] - [RequiresPermission("memberships")] - [HttpGet("")] - public async Task>> GetAllPlaces([FromQuery] bool includeInactive = false) - => Response(await _placeService.GetAllPlaces(new { includeInactive }, HttpContext)); - - [CustomMiddleware] - [TenantResolution] - [RequiresPermission("memberships")] - [HttpPost("")] - public async Task>> CreatePlace([FromBody] CreatePlaceRequest request) - => Response(await _placeService.CreatePlace(new - { - name = request.Name, - description = request.Description - }, HttpContext)); - - [CustomMiddleware] - [TenantResolution] - [RequiresPermission("memberships")] - [HttpPut("{id:guid}")] - public async Task>> UpdatePlace(Guid id, [FromBody] UpdatePlaceRequest request) - { - var payload = new Dictionary { ["placeId"] = id }; - if (request.Name != null) payload["name"] = request.Name; - if (request.Description != null) payload["description"] = request.Description; - if (request.IsActive.HasValue) payload["isActive"] = request.IsActive.Value; - - return Response(await _placeService.UpdatePlace(payload, HttpContext)); - } - - [CustomMiddleware] - [TenantResolution] - [RequiresPermission("memberships")] - [HttpPatch("{id:guid}/status")] - public async Task>> SetPlaceStatus(Guid id, [FromBody] SetPlaceStatusRequest request) - => Response(await _placeService.SetPlaceStatus(new { placeId = id, isActive = request.IsActive }, HttpContext)); - - [CustomMiddleware] - [TenantResolution] - [RequiresPermission("memberships")] - [HttpPut("{id:guid}/device")] - public async Task>> AssignDevice(Guid id, [FromBody] AssignPlaceDeviceRequest request) - => Response(await _placeService.AssignDevice(new { placeId = id, deviceId = request.DeviceId }, HttpContext)); - } -} diff --git a/Controllers/RfidTagController.cs b/Controllers/RfidTagController.cs new file mode 100644 index 0000000..e01671d --- /dev/null +++ b/Controllers/RfidTagController.cs @@ -0,0 +1,54 @@ +using Fitness_core_BE.DTOs.RfidTag; +using Fitness_core_BE.Helpers; +using Fitness_core_BE.Interfaces.Services; +using Fitness_core_BE.Models.IOs; +using Microsoft.AspNetCore.Mvc; +using static Fitness_core_BE.Helpers.JwtMiddleware; +using static Fitness_core_BE.Helpers.ApiResponseHelper; + +namespace Fitness_core_BE.Controllers +{ + // Staff-facing RFID tag inventory -- the pool of physical tags handed to a booked party at + // check-in (Booking.FKRfidTagId). "InUse" is never staff-settable here, only reached via a + // booking check-in/complete. + [ApiController] + [Route("api/client/rfid-tags")] + public class RfidTagController(IRfidTagService rfidTagService) : ControllerBase + { + private readonly IRfidTagService _rfidTagService = rfidTagService; + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("bookings")] + [HttpGet("")] + public async Task>> GetAllTags([FromQuery] string? status) + => Response(await _rfidTagService.GetAllTags(new { status }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("bookings")] + [HttpPost("")] + public async Task>> CreateTag([FromBody] CreateRfidTagRequest request) + => Response(await _rfidTagService.CreateTag(new { tagCode = request.TagCode, label = request.Label }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("bookings")] + [HttpPut("{id:guid}")] + public async Task>> UpdateTag(Guid id, [FromBody] UpdateRfidTagRequest request) + { + var payload = new Dictionary { ["tagId"] = id }; + if (request.TagCode != null) payload["tagCode"] = request.TagCode; + if (request.Label != null) payload["label"] = request.Label; + + return Response(await _rfidTagService.UpdateTag(payload, HttpContext)); + } + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("bookings")] + [HttpPatch("{id:guid}/status")] + public async Task>> SetTagStatus(Guid id, [FromBody] SetRfidTagStatusRequest request) + => Response(await _rfidTagService.SetTagStatus(new { tagId = id, status = request.Status }, HttpContext)); + } +} diff --git a/DTOs/Booking/BookingDto.cs b/DTOs/Booking/BookingDto.cs index 9a58ed0..9be538e 100644 --- a/DTOs/Booking/BookingDto.cs +++ b/DTOs/Booking/BookingDto.cs @@ -3,10 +3,22 @@ namespace Fitness_core_BE.DTOs.Booking public class BookingDto { public Guid BookingId { get; set; } - public Guid FacilitySessionId { get; set; } + + /// Null for an AdHoc booking (no pre-created session). + public Guid? FacilitySessionId { get; set; } + public Guid FacilityId { get; set; } public string? FacilityName { get; set; } - public DateTime? SessionStartTime { get; set; } - public DateTime? SessionEndTime { get; set; } + + /// "Scheduled" | "AdHoc" -- read off the facility, tells the FE which action set + /// applies (e.g. AdHoc bookings can be rescheduled to any free time; Scheduled ones only + /// to another session). + public string BookingMode { get; set; } = string.Empty; + + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + + /// Informational headcount, AdHoc only. + public int? PartySize { get; set; } public bool IsGuest { get; set; } public Guid? CustomerId { get; set; } @@ -18,11 +30,17 @@ namespace Fitness_core_BE.DTOs.Booking public string? OtpCode { get; set; } public bool OtpVerified { get; set; } + public Guid? RfidTagId { get; set; } + public string? RfidTagLabel { get; set; } + public DateTime? RfidAssignedAt { get; set; } + public DateTime? RfidReturnedAt { get; set; } + public string PaymentStatus { get; set; } = string.Empty; public decimal AmountCharged { get; set; } public string Status { get; set; } = string.Empty; public DateTime BookedAt { get; set; } public DateTime? CheckedInAt { get; set; } + public DateTime? CheckedOutAt { get; set; } /// A public, unauthenticated link to this booking's confirmation page -- /// copy-paste-and-share receipt. Works for both member and guest bookings. diff --git a/DTOs/Booking/BookingRequests.cs b/DTOs/Booking/BookingRequests.cs index 652d5d0..690d2ae 100644 --- a/DTOs/Booking/BookingRequests.cs +++ b/DTOs/Booking/BookingRequests.cs @@ -20,6 +20,40 @@ namespace Fitness_core_BE.DTOs.Booking public string GuestPhone { get; set; } = string.Empty; } + public class CreateAdhocBookingRequest + { + [Required] + public Guid FacilityId { get; set; } + + [Required] + public DateTime StartTime { get; set; } + + [Required, Range(1, int.MaxValue, ErrorMessage = "DurationMinutes must be at least 1")] + public int DurationMinutes { get; set; } + + public int? PartySize { get; set; } + } + + public class CreateGuestAdhocBookingRequest + { + [Required] + public Guid FacilityId { get; set; } + + [Required] + public DateTime StartTime { get; set; } + + [Required, Range(1, int.MaxValue, ErrorMessage = "DurationMinutes must be at least 1")] + public int DurationMinutes { get; set; } + + [Required] + public string GuestName { get; set; } = string.Empty; + + [Required] + public string GuestPhone { get; set; } = string.Empty; + + public int? PartySize { get; set; } + } + public class MarkGuestBookingPaidRequest { [Required] @@ -34,6 +68,11 @@ namespace Fitness_core_BE.DTOs.Booking { [Required] public string OtpCode { get; set; } = string.Empty; + + /// Picked from the club's managed RFID tag pool (GET api/client/rfid-tags? + /// status=Available) -- never free-typed. Required in practice for an AdHoc booking's + /// check-in; optional for Scheduled bookings that don't hand out a physical tag. + public Guid? RfidTagId { get; set; } } public class CancelBookingRequest @@ -41,4 +80,26 @@ namespace Fitness_core_BE.DTOs.Booking [Required] public Guid BookingId { get; set; } } + + public class ReassignBookingRequest + { + public string? GuestName { get; set; } + public string? GuestPhone { get; set; } + } + + /// Exactly one shape applies, matching the booking's current BookingMode: AdHoc + /// bookings move within the SAME facility to a new StartTime/DurationMinutes; Scheduled + /// bookings move to a different FacilitySession (also on the same facility). Only allowed + /// while the booking is still Confirmed, and only onto a slot with room -- re-runs the same + /// availability check as creation, excluding the booking's own current slot. + public class RescheduleBookingRequest + { + // AdHoc reschedule. + public DateTime? StartTime { get; set; } + [Range(1, int.MaxValue, ErrorMessage = "DurationMinutes must be at least 1")] + public int? DurationMinutes { get; set; } + + // Scheduled reschedule. + public Guid? FacilitySessionId { get; set; } + } } diff --git a/DTOs/Facility/FacilityDtos.cs b/DTOs/Facility/FacilityDtos.cs index 5fa9162..803d268 100644 --- a/DTOs/Facility/FacilityDtos.cs +++ b/DTOs/Facility/FacilityDtos.cs @@ -4,11 +4,19 @@ namespace Fitness_core_BE.DTOs.Facility { public Guid FacilityId { get; set; } public string Name { get; set; } = string.Empty; - public string FacilityType { get; set; } = string.Empty; + + /// "Scheduled" | "AdHoc". + public string BookingMode { get; set; } = "Scheduled"; public string? Description { get; set; } public int DefaultCapacity { get; set; } + public int NumberOfUnits { get; set; } + public decimal PricePerHour { get; set; } public bool IsActive { get; set; } public DateTime CreatedAt { get; set; } + + // Absorbed from the old PlaceDto -- at most one for now, see AccessDevice.FKFacilityId. + public Guid? DeviceId { get; set; } + public string? DeviceName { get; set; } } public class FacilitySessionDto @@ -22,4 +30,17 @@ namespace Fitness_core_BE.DTOs.Facility public int BookedCount { get; set; } public bool IsCancelled { get; set; } } + + /// GET api/client/facilities/availability -- how much room is left for a requested + /// window, uniformly across both booking modes (TotalUnits/UnitsFree mean NumberOfUnits/ + /// overlap-count for AdHoc, DefaultCapacity/free-session-spots for Scheduled). + public class FacilityAvailabilityDto + { + public Guid FacilityId { get; set; } + public string FacilityName { get; set; } = string.Empty; + public string BookingMode { get; set; } = string.Empty; + public int TotalUnits { get; set; } + public int UnitsFree { get; set; } + public decimal PricePerHour { get; set; } + } } diff --git a/DTOs/Facility/FacilityRequests.cs b/DTOs/Facility/FacilityRequests.cs index 4c77a1c..f0dc76b 100644 --- a/DTOs/Facility/FacilityRequests.cs +++ b/DTOs/Facility/FacilityRequests.cs @@ -7,14 +7,22 @@ namespace Fitness_core_BE.DTOs.Facility [Required] public string Name { get; set; } = string.Empty; - /// "Gym" | "Pool" | "Court" | "Class". + /// "Scheduled" | "AdHoc". [Required] - public string FacilityType { get; set; } = "Class"; + public string BookingMode { get; set; } = "Scheduled"; public string? Description { get; set; } + /// Required (meaningfully) when BookingMode == "Scheduled". [Range(1, int.MaxValue, ErrorMessage = "DefaultCapacity must be at least 1")] public int DefaultCapacity { get; set; } = 1; + + /// Required (meaningfully) when BookingMode == "AdHoc" -- e.g. 3 for "3 courts". + [Range(1, int.MaxValue, ErrorMessage = "NumberOfUnits must be at least 1")] + public int NumberOfUnits { get; set; } = 1; + + [Range(typeof(decimal), "0", "79228162514264337593543950335", ErrorMessage = "PricePerHour cannot be negative")] + public decimal PricePerHour { get; set; } = 0m; } public class UpdateFacilityRequest @@ -22,9 +30,11 @@ namespace Fitness_core_BE.DTOs.Facility [Required] public Guid FacilityId { get; set; } public string? Name { get; set; } - public string? FacilityType { get; set; } + public string? BookingMode { get; set; } public string? Description { get; set; } public int? DefaultCapacity { get; set; } + public int? NumberOfUnits { get; set; } + public decimal? PricePerHour { get; set; } public bool? IsActive { get; set; } } @@ -48,4 +58,22 @@ namespace Fitness_core_BE.DTOs.Facility /// Overrides the facility's default capacity for this specific session. public int? Capacity { get; set; } } + + /// Absorbed from the old AssignPlaceDeviceRequest. + public class AssignFacilityDeviceRequest + { + // Null unassigns the facility's current device. + public Guid? DeviceId { get; set; } + } + + public class GetFacilityAvailabilityRequest + { + public Guid? FacilityId { get; set; } + + [Required] + public DateTime StartTime { get; set; } + + [Required, Range(1, int.MaxValue, ErrorMessage = "DurationMinutes must be at least 1")] + public int DurationMinutes { get; set; } + } } diff --git a/DTOs/Place/PlaceDtos.cs b/DTOs/Place/PlaceDtos.cs deleted file mode 100644 index f82db33..0000000 --- a/DTOs/Place/PlaceDtos.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Fitness_core_BE.DTOs.Place -{ - public class PlaceDto - { - public Guid PlaceId { get; set; } - public string Name { get; set; } = string.Empty; - public string? Description { get; set; } - public bool IsActive { get; set; } - public DateTime CreatedAt { get; set; } - - // At most one for now -- see AccessDevice.FKPlaceId. - public Guid? DeviceId { get; set; } - public string? DeviceName { get; set; } - } -} diff --git a/DTOs/Place/PlaceRequests.cs b/DTOs/Place/PlaceRequests.cs deleted file mode 100644 index f6ed978..0000000 --- a/DTOs/Place/PlaceRequests.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Fitness_core_BE.DTOs.Place -{ - public class CreatePlaceRequest - { - [Required] - public string Name { get; set; } = string.Empty; - public string? Description { get; set; } - } - - public class UpdatePlaceRequest - { - [Required] - public Guid PlaceId { get; set; } - public string? Name { get; set; } - public string? Description { get; set; } - public bool? IsActive { get; set; } - } - - public class SetPlaceStatusRequest - { - [Required] - public bool IsActive { get; set; } - } - - public class AssignPlaceDeviceRequest - { - // Null unassigns the place's current device. - public Guid? DeviceId { get; set; } - } -} diff --git a/DTOs/RfidTag/RfidTagDtos.cs b/DTOs/RfidTag/RfidTagDtos.cs new file mode 100644 index 0000000..63ce3e9 --- /dev/null +++ b/DTOs/RfidTag/RfidTagDtos.cs @@ -0,0 +1,14 @@ +namespace Fitness_core_BE.DTOs.RfidTag +{ + public class RfidTagDto + { + public Guid RfidTagId { get; set; } + public string TagCode { get; set; } = string.Empty; + public string? Label { get; set; } + + /// "Available" | "InUse" | "Lost" | "Retired". + public string Status { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + } +} diff --git a/DTOs/RfidTag/RfidTagRequests.cs b/DTOs/RfidTag/RfidTagRequests.cs new file mode 100644 index 0000000..59cbfd6 --- /dev/null +++ b/DTOs/RfidTag/RfidTagRequests.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fitness_core_BE.DTOs.RfidTag +{ + public class CreateRfidTagRequest + { + [Required] + public string TagCode { get; set; } = string.Empty; + public string? Label { get; set; } + } + + public class UpdateRfidTagRequest + { + public string? TagCode { get; set; } + public string? Label { get; set; } + } + + public class SetRfidTagStatusRequest + { + /// "Available" | "Lost" | "Retired" only -- "InUse" is never staff-settable + /// directly, only reached via a booking check-in. + [Required] + public string Status { get; set; } = string.Empty; + } +} diff --git a/Helpers/BookingViewHtml.cs b/Helpers/BookingViewHtml.cs index d640c61..d2e3ae2 100644 --- a/Helpers/BookingViewHtml.cs +++ b/Helpers/BookingViewHtml.cs @@ -11,9 +11,7 @@ namespace Fitness_core_BE.Helpers public static string Render(BookingDto booking) { var who = booking.IsGuest ? Encode(booking.GuestName) : "Member booking"; - var when = booking.SessionStartTime.HasValue - ? $"{booking.SessionStartTime:dddd, dd MMM yyyy}, {booking.SessionStartTime:h:mm tt} - {booking.SessionEndTime:h:mm tt}" - : "-"; + var when = $"{booking.StartTime:dddd, dd MMM yyyy}, {booking.StartTime:h:mm tt} - {booking.EndTime:h:mm tt}"; var statusColor = booking.Status switch { diff --git a/Interfaces/IBookingRepository.cs b/Interfaces/IBookingRepository.cs index 805d8f2..ec3e4ec 100644 --- a/Interfaces/IBookingRepository.cs +++ b/Interfaces/IBookingRepository.cs @@ -13,5 +13,27 @@ namespace Fitness_core_BE.Interfaces Task> GetBookingsForSession(Guid facilitySessionId); Task> GetBookingsForCustomer(Guid customerId); Task CustomerHasActiveBookingForSession(Guid facilitySessionId, Guid customerId); + + /// Unified roster query covering both Scheduled and AdHoc bookings -- backs + /// GET api/client/bookings. All filters are optional/AND-combined. + Task> GetBookings(Guid clientId, Guid? facilityId, Guid? facilitySessionId, DateTime? fromUtc, DateTime? toUtc, string? status); + + // --- Lazy overdue reconciliation (BookingService.SweepOverdueBookingsAsync), run at the + // top of every check-in instead of a background job -- see Booking.Status doc comment. --- + + /// Bulk-flips every Confirmed booking for this client whose EndTime has passed + /// to NoShow. Returns the number of rows affected (informational/logging only). + Task MarkOverdueConfirmedAsNoShowAsync(Guid clientId, DateTime now); + + /// Every CheckedIn booking for this client whose EndTime has passed and hasn't + /// been checked out yet, with its RfidTag eagerly loaded so the caller can complete + + /// release the tag on the same tracked graph in one SaveChanges. + Task> GetOverdueCheckedInBookingsAsync(Guid clientId, DateTime now); + + /// Atomically flips a tag from Available to InUse -- the ONE place a real race + /// matters (two staff picking the same tag for two different bookings must not both + /// win), so this uses a conditional bulk update rather than read-then-write. Returns + /// false if the tag was no longer Available. + Task TryClaimRfidTagAsync(Guid tagId, DateTime now); } } diff --git a/Interfaces/IFacilityRepository.cs b/Interfaces/IFacilityRepository.cs index 8ee063d..2d49d34 100644 --- a/Interfaces/IFacilityRepository.cs +++ b/Interfaces/IFacilityRepository.cs @@ -15,5 +15,31 @@ namespace Fitness_core_BE.Interfaces Task> GetSessions(Guid clientId, Guid? facilityId, DateTime fromUtc, DateTime toUtc); Task GetActiveBookingCount(Guid facilitySessionId); Task CancelFacilitySession(Guid facilitySessionId, Guid clientId); + + // Device binding (absorbed from the old IPlaceRepo -- one AccessDevice per Facility). + Task GetDeviceByIdForClientAsync(Guid deviceId, Guid clientId); + Task> GetDevicesByFacilityIdsAsync(List facilityIds); + Task IsDeviceAssignedToAnotherFacilityAsync(Guid deviceId, Guid facilityId); + Task AssignDeviceAsync(Guid facilityId, Guid deviceId); + Task UnassignDeviceAsync(Guid facilityId); + + // CustomerPlan <-> Facility access-scoping (many-to-many). Replace-all semantics, like a + // tags field. Absorbed from the old IPlaceRepo. + Task SetPlanFacilitiesAsync(Guid customerPlanId, List facilityIds); + Task> GetFacilityIdsForPlanAsync(Guid customerPlanId); + Task>> GetFacilityIdsByPlanIdsAsync(List customerPlanIds); + Task> GetFacilityNamesByIdsAsync(List facilityIds); + + // AdHoc booking support (new). See TenantDbContext's Booking (FKFacilityId, StartTime, + // EndTime) index comment -- overlap is checked here rather than by a DB constraint. + /// Counts non-cancelled/non-completed AdHoc bookings on this facility whose + /// [StartTime, EndTime) overlaps the given window. Pass excludeBookingId when + /// re-checking a booking being rescheduled, so it doesn't count against itself. + Task GetOverlappingAdhocBookingCountAsync(Guid facilityId, DateTime startTime, DateTime endTime, Guid? excludeBookingId = null); + + /// Issues a row lock on the facility (SELECT ... FOR UPDATE) inside the caller's + /// already-open unit-of-work transaction, serializing concurrent AdHoc-slot claims on the + /// same facility so two overlapping bookings can't both pass the overlap-count check. + Task LockFacilityForUpdateAsync(Guid facilityId); } } diff --git a/Interfaces/IPlaceRepo.cs b/Interfaces/IPlaceRepo.cs deleted file mode 100644 index cf6e568..0000000 --- a/Interfaces/IPlaceRepo.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Fitness_core_BE.Models; - -namespace Fitness_core_BE.Interfaces -{ - public interface IPlaceRepo - { - Task> GetAllPlaces(Guid clientId, bool includeInactive = false); - Task GetPlaceById(Guid placeId); - Task CreatePlace(Place place); - Task UpdatePlace(Place place); - Task SetPlaceActive(Guid placeId, Guid clientId, bool isActive); - - Task GetDeviceByIdForClientAsync(Guid deviceId, Guid clientId); - Task> GetDevicesByPlaceIdsAsync(List placeIds); - Task IsDeviceAssignedToAnotherPlaceAsync(Guid deviceId, Guid placeId); - Task AssignDeviceAsync(Guid placeId, Guid deviceId); - Task UnassignDeviceAsync(Guid placeId); - - // CustomerPlan <-> Place (many-to-many). Replace-all semantics, like a tags field. - Task SetPlanPlacesAsync(Guid customerPlanId, List placeIds); - Task> GetPlaceIdsForPlanAsync(Guid customerPlanId); - Task>> GetPlaceIdsByPlanIdsAsync(List customerPlanIds); - Task> GetPlaceNamesByIdsAsync(List placeIds); - } -} diff --git a/Interfaces/IRfidTagRepository.cs b/Interfaces/IRfidTagRepository.cs new file mode 100644 index 0000000..54dd6f0 --- /dev/null +++ b/Interfaces/IRfidTagRepository.cs @@ -0,0 +1,18 @@ +using Fitness_core_BE.Models; + +namespace Fitness_core_BE.Interfaces +{ + public interface IRfidTagRepository + { + Task> GetAllTags(Guid clientId, string? status = null); + Task GetTagById(Guid tagId, Guid clientId); + Task TagCodeExistsAsync(Guid clientId, string tagCode, Guid? excludeTagId = null); + Task CreateTag(RfidTag tag); + Task UpdateTag(RfidTag tag); + + /// Only for staff-driven transitions (Available/Lost/Retired) -- rejects if the + /// tag is currently InUse, since that transition only happens via booking check-in/ + /// complete/sweep. + Task SetStatusAsync(Guid tagId, Guid clientId, string status); + } +} diff --git a/Interfaces/Services/IBookingService.cs b/Interfaces/Services/IBookingService.cs index 44b1e0b..40b84f1 100644 --- a/Interfaces/Services/IBookingService.cs +++ b/Interfaces/Services/IBookingService.cs @@ -10,6 +10,13 @@ namespace Fitness_core_BE.Interfaces.Services /// No membership at all -- pay-per-session, identity proof is the returned OTP. Task CreateGuestBooking(object payload, HttpContext httpContext); + /// Member books an AdHoc-mode facility directly for an arbitrary time window, no + /// pre-created session. + Task CreateMemberAdhocBooking(object payload, HttpContext httpContext); + + /// Guest/staff walk-in books an AdHoc-mode facility directly. + Task CreateGuestAdhocBooking(object payload, HttpContext httpContext); + /// Renders the public, unauthenticated confirmation page a booking's ShareUrl /// points to -- the "copy the link and share it" receipt. Returns null if not found, so /// the controller can render a 404 page instead of a JSON error for a browser hit. @@ -18,11 +25,32 @@ namespace Fitness_core_BE.Interfaces.Services /// Staff records a guest booking's payment (cash/bank/at-venue) after the fact. Task MarkGuestBookingPaid(object payload, HttpContext httpContext); - /// Staff/door check-in: verifies the OTP and marks the booking checked in. + /// Staff/door check-in: verifies the OTP, optionally assigns an RFID tag, marks + /// the booking checked in. Also lazily sweeps this client's overdue bookings first (see + /// BookingService.SweepOverdueBookingsAsync) instead of a background job. Task CheckInBooking(object payload, HttpContext httpContext); Task CancelBooking(object payload, HttpContext httpContext); + + /// Staff hands a Confirmed guest booking to a different person (name/phone) -- + /// no cancel/recreate needed. Guest bookings only. + Task ReassignBooking(object payload, HttpContext httpContext); + + /// Staff moves a Confirmed booking to a different time (AdHoc) or session + /// (Scheduled), only onto a slot with room. + Task RescheduleBooking(object payload, HttpContext httpContext); + + /// Staff manually flags a Confirmed, never-arrived booking. + Task MarkNoShow(object payload, HttpContext httpContext); + + /// Staff manually ends a CheckedIn booking early (before the lazy sweep would + /// catch it), releasing its RFID tag if any. + Task CompleteBooking(object payload, HttpContext httpContext); + Task GetMyBookings(object payload, HttpContext httpContext); Task GetSessionBookings(object payload, HttpContext httpContext); + + /// Unified roster covering both booking modes. + Task GetBookings(object payload, HttpContext httpContext); } } diff --git a/Interfaces/Services/IFacilityService.cs b/Interfaces/Services/IFacilityService.cs index 00db98a..6e42c05 100644 --- a/Interfaces/Services/IFacilityService.cs +++ b/Interfaces/Services/IFacilityService.cs @@ -12,5 +12,10 @@ namespace Fitness_core_BE.Interfaces.Services Task CreateFacilitySession(object payload, HttpContext httpContext); Task GetSessions(object payload, HttpContext httpContext); Task CancelFacilitySession(object payload, HttpContext httpContext); + + // Absorbed from the old IPlaceService. + Task AssignDevice(object payload, HttpContext httpContext); + + Task GetAvailability(object payload, HttpContext httpContext); } } diff --git a/Interfaces/Services/IPlaceService.cs b/Interfaces/Services/IPlaceService.cs deleted file mode 100644 index ae8f234..0000000 --- a/Interfaces/Services/IPlaceService.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace Fitness_core_BE.Interfaces.Services -{ - public interface IPlaceService - { - Task GetAllPlaces(object payload, HttpContext httpContext); - Task CreatePlace(object payload, HttpContext httpContext); - Task UpdatePlace(object payload, HttpContext httpContext); - Task SetPlaceStatus(object payload, HttpContext httpContext); - Task AssignDevice(object payload, HttpContext httpContext); - } -} diff --git a/Interfaces/Services/IRfidTagService.cs b/Interfaces/Services/IRfidTagService.cs new file mode 100644 index 0000000..ba60889 --- /dev/null +++ b/Interfaces/Services/IRfidTagService.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Http; + +namespace Fitness_core_BE.Interfaces.Services +{ + public interface IRfidTagService + { + Task GetAllTags(object payload, HttpContext httpContext); + Task CreateTag(object payload, HttpContext httpContext); + Task UpdateTag(object payload, HttpContext httpContext); + Task SetTagStatus(object payload, HttpContext httpContext); + } +} diff --git a/Models/AccessDevice.cs b/Models/AccessDevice.cs index c5f3d3a..0cc5f39 100644 --- a/Models/AccessDevice.cs +++ b/Models/AccessDevice.cs @@ -37,13 +37,13 @@ namespace Fitness_core_BE.Models [MaxLength(160)] public string? HardwareId { get; set; } - // Which Place (physical zone) this terminal sits in. Null = unscoped: reachable by - // every plan regardless of that plan's Places (keeps pre-existing devices and - // plans without any place restriction working unchanged). Unique when set -- - // exactly one device per place for now (see Models/Place.cs). - public Guid? FKPlaceId { get; set; } - [ForeignKey(nameof(FKPlaceId))] - public Place? Place { get; set; } + // Which Facility (physical zone) this terminal sits in. Null = unscoped: reachable by + // every plan regardless of that plan's Facilities (keeps pre-existing devices and + // plans without any facility restriction working unchanged). Unique when set -- + // exactly one device per facility for now (see Models/Facility.cs). + public Guid? FKFacilityId { get; set; } + [ForeignKey(nameof(FKFacilityId))] + public Facility? Facility { get; set; } public bool IsActive { get; set; } = true; public DateTime? LastSeenAt { get; set; } diff --git a/Models/Booking.cs b/Models/Booking.cs index f7cf62b..fa174a0 100644 --- a/Models/Booking.cs +++ b/Models/Booking.cs @@ -3,19 +3,53 @@ using System.ComponentModel.DataAnnotations.Schema; namespace Fitness_core_BE.Models { - /// One reservation against a FacilitySession — either an existing member using a - /// CustomerPlan session allowance (FKCustomerId set, IsGuest = false) or a non-member who - /// booked online and pays per-session (IsGuest = true, no membership at all). The guest's - /// identity proof for check-in is the OTP issued at booking time, verified at the door - /// instead of a fingerprint/face profile a non-member will never have. + /// One reservation against a Facility — either a Scheduled-mode booking against a + /// pre-created FacilitySession (many bookings share one capacity pool), or an AdHoc-mode + /// booking that claims one of the Facility's NumberOfUnits directly for an arbitrary time + /// window, no session needed. Either an existing member using a CustomerPlan session + /// allowance (FKCustomerId set, IsGuest = false) or a non-member who booked online and pays + /// per-session (IsGuest = true, no membership at all). The guest's identity proof for + /// check-in is the OTP issued at booking time, verified at the door instead of a + /// fingerprint/face profile a non-member will never have. public class Booking { [Key] public Guid BookingId { get; set; } - public Guid FKFacilitySessionId { get; set; } + /// Null for an AdHoc booking (no pre-created session). + public Guid? FKFacilitySessionId { get; set; } [ForeignKey(nameof(FKFacilitySessionId))] - public FacilitySession FacilitySession { get; set; } + public FacilitySession? FacilitySession { get; set; } + + /// Always set, for both modes -- copied from FacilitySession.FKFacilityId at + /// creation (Scheduled) or the booked facility directly (AdHoc). Every place that needs + /// "which facility / what time window" reads these (and StartTime/EndTime below) + /// uniformly instead of reaching through FacilitySession, which doesn't exist for AdHoc + /// bookings. + public Guid FKFacilityId { get; set; } + [ForeignKey(nameof(FKFacilityId))] + public Facility Facility { get; set; } + + /// Copied from FacilitySession.StartTime (Scheduled) or the requested start + /// (AdHoc) at creation; updated in place by Reschedule. + public DateTime StartTime { get; set; } + /// Same pattern as StartTime. + public DateTime EndTime { get; set; } + + /// Informational headcount for an AdHoc booking (e.g. "how many will come") -- + /// never enforced as a capacity mechanic; each AdHoc booking claims one whole unit + /// regardless of party size. Null for Scheduled bookings. + public int? PartySize { get; set; } + + /// The RFID tag handed to the party at check-in, picked from the club's managed + /// tag inventory (RfidTag) -- never free-typed. Released back to "Available" by the + /// Complete action or the no-show/completion sweep, whichever runs first (both use the + /// same idempotent guarded update, so a race between them is a harmless no-op). + public Guid? FKRfidTagId { get; set; } + [ForeignKey(nameof(FKRfidTagId))] + public RfidTag? RfidTag { get; set; } + public DateTime? RfidAssignedAt { get; set; } + public DateTime? RfidReturnedAt { get; set; } public Guid FKClientId { get; set; } [ForeignKey(nameof(FKClientId))] @@ -58,11 +92,19 @@ namespace Fitness_core_BE.Models [ForeignKey(nameof(FKPaymentTransactionId))] public PaymentTransaction? PaymentTransaction { get; set; } - /// "Confirmed" | "CheckedIn" | "Cancelled" | "NoShow". + /// "Confirmed" | "CheckedIn" | "Cancelled" | "NoShow" | "Completed". NoShow and + /// Completed are reached either by explicit staff action (MarkNoShow/Complete) or lazily, + /// swept by BookingService.SweepOverdueBookingsAsync at the top of every check-in call + /// (not a background job -- overdue bookings are reconciled the next time any check-in + /// happens for this client, across every facility). public string Status { get; set; } = "Confirmed"; public DateTime BookedAt { get; set; } = DateTime.UtcNow; public DateTime? CheckedInAt { get; set; } + /// Set when the booking reaches Completed, whether via the manual Complete + /// action or the lazy overdue-sweep -- the actual moment it was reconciled, not + /// necessarily EndTime. + public DateTime? CheckedOutAt { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } } diff --git a/Models/CustomerPlanFacility.cs b/Models/CustomerPlanFacility.cs new file mode 100644 index 0000000..9a20ef9 --- /dev/null +++ b/Models/CustomerPlanFacility.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + /// Many-to-many join: which Facility(ies) a CustomerPlan grants access to. A plan + /// with no rows here is unrestricted (reaches every active device) -- see + /// AccessDeviceService's allowed-device resolution. NOTE: this access-scoping is stored and + /// surfaced in the UI but not actually enforced by provisioning today -- a pre-existing gap, + /// unrelated to the Facility/Place rename, left exactly as unenforced as it was when this + /// table was named CustomerPlanPlace. + public class CustomerPlanFacility + { + [Key] + public Guid CustomerPlanFacilityId { get; set; } + + public Guid FKCustomerPlanId { get; set; } + [ForeignKey(nameof(FKCustomerPlanId))] + public CustomerPlan CustomerPlan { get; set; } = null!; + + public Guid FKFacilityId { get; set; } + [ForeignKey(nameof(FKFacilityId))] + public Facility Facility { get; set; } = null!; + } +} diff --git a/Models/CustomerPlanPlace.cs b/Models/CustomerPlanPlace.cs deleted file mode 100644 index 1cd6dd9..0000000 --- a/Models/CustomerPlanPlace.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Fitness_core_BE.Models -{ - /// Many-to-many join: which Place(s) a CustomerPlan grants access to. A plan - /// with no rows here is unrestricted (reaches every active device) -- see - /// AccessDeviceService's allowed-device resolution. - public class CustomerPlanPlace - { - [Key] - public Guid CustomerPlanPlaceId { get; set; } - - public Guid FKCustomerPlanId { get; set; } - [ForeignKey(nameof(FKCustomerPlanId))] - public CustomerPlan CustomerPlan { get; set; } = null!; - - public Guid FKPlaceId { get; set; } - [ForeignKey(nameof(FKPlaceId))] - public Place Place { get; set; } = null!; - } -} diff --git a/Models/Facility.cs b/Models/Facility.cs index 4364a82..a4ef08c 100644 --- a/Models/Facility.cs +++ b/Models/Facility.cs @@ -3,29 +3,46 @@ using System.ComponentModel.DataAnnotations.Schema; namespace Fitness_core_BE.Models { - /// A bookable area of the club — the gym floor, the pool, a court, a class type - /// (karate, etc). Gym-floor access itself keeps working through the existing - /// CustomerPlan/Attendance flow unchanged; Facility/FacilitySession/Booking below are for - /// the new capacity-limited, session-based areas (pool, courts, classes). + /// A physical area of the club (e.g. "Pool Area", "Indoor Gym", "Court 1") that can + /// both be booked (Booking/FacilitySession below) and fitted with its own fingerprint/face + /// terminal for membership-plan access scoping (AccessDevice.FKFacilityId, + /// CustomerPlanFacility). Merged from the formerly-separate Facility (bookable capacity) and + /// Place (access zone) concepts -- see the booking redesign plan for the migration that + /// carried Place's rows over reusing this table's original PlaceId values as FacilityId. public class Facility { [Key] public Guid FacilityId { get; set; } + public Guid FKClientId { get; set; } [ForeignKey(nameof(FKClientId))] public Client Client { get; set; } + [MaxLength(128)] public string Name { get; set; } = string.Empty; - /// "Gym" | "Pool" | "Court" | "Class" — informational grouping for the admin - /// UI; booking rules themselves live on FacilitySession/CustomerPlan, not on this type. - public string FacilityType { get; set; } = "Class"; + /// "Scheduled" | "AdHoc". Scheduled: staff pre-creates fixed-time + /// FacilitySessions that many bookings share one capacity pool against (classes). + /// AdHoc: a booking claims one of NumberOfUnits identical units directly for an + /// arbitrary time window, no pre-created session needed (courts/pool). + public string BookingMode { get; set; } = "Scheduled"; + [MaxLength(500)] public string? Description { get; set; } - /// Default per-session headcount limit; a specific FacilitySession can override. + /// Default per-session headcount limit when BookingMode == "Scheduled"; a + /// specific FacilitySession can override. public int DefaultCapacity { get; set; } = 1; + /// Number of identical, independently-bookable units when BookingMode == + /// "AdHoc" (e.g. 3 for "3 courts") -- availability is this minus overlapping + /// non-cancelled/non-completed AdHoc bookings in the requested window. + public int NumberOfUnits { get; set; } = 1; + + /// Hourly rate used to compute a guest AdHoc booking's suggested AmountCharged + /// (PricePerHour * duration-in-hours) at creation time. + public decimal PricePerHour { get; set; } = 0m; + public bool IsActive { get; set; } = true; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } diff --git a/Models/Place.cs b/Models/Place.cs deleted file mode 100644 index 9867d36..0000000 --- a/Models/Place.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -namespace Fitness_core_BE.Models -{ - /// A physical access zone within the club (e.g. "Pool Area", "Indoor Gym"), - /// each fitted with its own fingerprint/face terminal. CustomerPlan grants access to - /// one or more Places via CustomerPlanPlace; AccessDevice.FKPlaceId says which Place a - /// terminal sits in. Deliberately separate from Facility, which models capacity-limited - /// bookable sessions (pool/court/class bookings) -- Places are about which physical - /// machine a member's access profile should reach, not about booking a time slot. - public class Place - { - [Key] - public Guid PlaceId { get; set; } - - public Guid FKClientId { get; set; } - [ForeignKey(nameof(FKClientId))] - public Client Client { get; set; } - - [MaxLength(128)] - public string Name { get; set; } = string.Empty; - - [MaxLength(500)] - public string? Description { get; set; } - - public bool IsActive { get; set; } = true; - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - } -} diff --git a/Models/RfidTag.cs b/Models/RfidTag.cs new file mode 100644 index 0000000..f2d7e96 --- /dev/null +++ b/Models/RfidTag.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fitness_core_BE.Models +{ + /// One physical RFID tag in a club's managed inventory, handed to a booked party at + /// check-in (Booking.FKRfidTagId) and returned when the booking completes or is swept as a + /// no-show. Deliberately a fresh, booking-scoped concept -- not TimeBankCard's RfidTagId, + /// which is a free-typed string on an unrelated prepaid-minutes card. Assignment is manual + /// (staff picks an Available tag) in this phase; the Status transitions are factored so a + /// later physical-reader integration can drive the same assign/return methods without this + /// model changing again. + public class RfidTag + { + [Key] + public Guid RfidTagId { get; set; } + + public Guid FKClientId { get; set; } + + /// The tag's printed/embedded serial. + [MaxLength(64)] + public string TagCode { get; set; } = string.Empty; + + /// Friendly name, e.g. "Red Band #12". + [MaxLength(100)] + public string? Label { get; set; } + + /// "Available" | "InUse" | "Lost" | "Retired". Only ever flips to "InUse" via a + /// booking check-in, and back via Complete/the sweep -- staff cannot set "InUse" directly + /// through the tag-management endpoints. + public string Status { get; set; } = "Available"; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Program.cs b/Program.cs index 3b716a0..a8e4040 100644 --- a/Program.cs +++ b/Program.cs @@ -143,7 +143,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); // Register Helpers builder.Services.AddScoped(); @@ -163,11 +163,11 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Repos/BookingRepository.cs b/Repos/BookingRepository.cs index 94b3fc5..146a3ce 100644 --- a/Repos/BookingRepository.cs +++ b/Repos/BookingRepository.cs @@ -24,23 +24,31 @@ namespace Fitness_core_BE.Repos public Task GetBookingById(Guid bookingId) => _context.Bookings - .Include(b => b.FacilitySession).ThenInclude(s => s.Facility) + .Include(b => b.FacilitySession) + .Include(b => b.Facility) + .Include(b => b.RfidTag) .FirstOrDefaultAsync(b => b.BookingId == bookingId); public Task GetActiveBookingByOtp(string otpCode) => _context.Bookings - .Include(b => b.FacilitySession).ThenInclude(s => s.Facility) + .Include(b => b.FacilitySession) + .Include(b => b.Facility) + .Include(b => b.RfidTag) .FirstOrDefaultAsync(b => b.OtpCode == otpCode && b.Status == "Confirmed"); public Task> GetBookingsForSession(Guid facilitySessionId) => _context.Bookings + .Include(b => b.Facility) + .Include(b => b.RfidTag) .Where(b => b.FKFacilitySessionId == facilitySessionId) .OrderBy(b => b.BookedAt) .ToListAsync(); public Task> GetBookingsForCustomer(Guid customerId) => _context.Bookings - .Include(b => b.FacilitySession).ThenInclude(s => s.Facility) + .Include(b => b.FacilitySession) + .Include(b => b.Facility) + .Include(b => b.RfidTag) .Where(b => b.FKCustomerId == customerId) .OrderByDescending(b => b.BookedAt) .ToListAsync(); @@ -50,5 +58,43 @@ namespace Fitness_core_BE.Repos b.FKFacilitySessionId == facilitySessionId && b.FKCustomerId == customerId && b.Status != "Cancelled"); + + public async Task> GetBookings(Guid clientId, Guid? facilityId, Guid? facilitySessionId, DateTime? fromUtc, DateTime? toUtc, string? status) + { + var query = _context.Bookings + .Include(b => b.FacilitySession) + .Include(b => b.Facility) + .Include(b => b.RfidTag) + .Where(b => b.FKClientId == clientId); + + if (facilityId.HasValue) query = query.Where(b => b.FKFacilityId == facilityId.Value); + if (facilitySessionId.HasValue) query = query.Where(b => b.FKFacilitySessionId == facilitySessionId.Value); + if (fromUtc.HasValue) query = query.Where(b => b.EndTime >= fromUtc.Value); + if (toUtc.HasValue) query = query.Where(b => b.StartTime <= toUtc.Value); + if (!string.IsNullOrEmpty(status)) query = query.Where(b => b.Status == status); + + return await query.OrderByDescending(b => b.StartTime).ToListAsync(); + } + + public Task MarkOverdueConfirmedAsNoShowAsync(Guid clientId, DateTime now) + => _context.Bookings + .Where(b => b.FKClientId == clientId && b.Status == "Confirmed" && b.EndTime < now) + .ExecuteUpdateAsync(s => s.SetProperty(b => b.Status, "NoShow")); + + public Task> GetOverdueCheckedInBookingsAsync(Guid clientId, DateTime now) + => _context.Bookings + .Include(b => b.RfidTag) + .Where(b => b.FKClientId == clientId && b.Status == "CheckedIn" && b.EndTime < now) + .ToListAsync(); + + public async Task TryClaimRfidTagAsync(Guid tagId, DateTime now) + { + var rows = await _context.RfidTags + .Where(t => t.RfidTagId == tagId && t.Status == "Available") + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.Status, "InUse") + .SetProperty(t => t.UpdatedAt, now)); + return rows > 0; + } } } diff --git a/Repos/FacilityRepository.cs b/Repos/FacilityRepository.cs index 1e55df3..e6c3dfb 100644 --- a/Repos/FacilityRepository.cs +++ b/Repos/FacilityRepository.cs @@ -41,9 +41,11 @@ namespace Fitness_core_BE.Repos ?? throw new KeyNotFoundException("Facility not found"); existing.Name = facility.Name; - existing.FacilityType = facility.FacilityType; + existing.BookingMode = facility.BookingMode; existing.Description = facility.Description; existing.DefaultCapacity = facility.DefaultCapacity; + existing.NumberOfUnits = facility.NumberOfUnits; + existing.PricePerHour = facility.PricePerHour; existing.IsActive = facility.IsActive; return existing; @@ -98,5 +100,121 @@ namespace Fitness_core_BE.Repos session.IsCancelled = true; return session; } + + // --- Device binding (absorbed from PlaceRepo) --- + + public Task GetDeviceByIdForClientAsync(Guid deviceId, Guid clientId) + => _context.AccessDevices + .FirstOrDefaultAsync(d => d.DeviceId == deviceId && d.FKClientId == clientId); + + public async Task> GetFacilityNamesByIdsAsync(List facilityIds) + { + return await _context.Facilities + .Where(f => facilityIds.Contains(f.FacilityId)) + .ToDictionaryAsync(f => f.FacilityId, f => f.Name); + } + + public async Task> GetDevicesByFacilityIdsAsync(List facilityIds) + { + var devices = await _context.AccessDevices + .Where(d => d.FKFacilityId != null && facilityIds.Contains(d.FKFacilityId.Value)) + .ToListAsync(); + + return devices.ToDictionary(d => d.FKFacilityId!.Value, d => d); + } + + public Task IsDeviceAssignedToAnotherFacilityAsync(Guid deviceId, Guid facilityId) + => _context.AccessDevices + .AnyAsync(d => d.DeviceId == deviceId && d.FKFacilityId != null && d.FKFacilityId != facilityId); + + public async Task AssignDeviceAsync(Guid facilityId, Guid deviceId) + { + // A device can only sit in one facility at a time -- clear anywhere else it's + // currently assigned before pointing it at this facility. + var previouslyAssigned = await _context.AccessDevices + .Where(d => d.FKFacilityId == facilityId && d.DeviceId != deviceId) + .ToListAsync(); + foreach (var device in previouslyAssigned) + device.FKFacilityId = null; + + var target = await _context.AccessDevices.FindAsync(deviceId) + ?? throw new KeyNotFoundException("Device not found"); + target.FKFacilityId = facilityId; + } + + public async Task UnassignDeviceAsync(Guid facilityId) + { + var devices = await _context.AccessDevices + .Where(d => d.FKFacilityId == facilityId) + .ToListAsync(); + foreach (var device in devices) + device.FKFacilityId = null; + } + + // --- CustomerPlan access-scoping (absorbed from PlaceRepo) --- + + public async Task SetPlanFacilitiesAsync(Guid customerPlanId, List facilityIds) + { + var existing = await _context.CustomerPlanFacilities + .Where(pf => pf.FKCustomerPlanId == customerPlanId) + .ToListAsync(); + _context.CustomerPlanFacilities.RemoveRange(existing); + + foreach (var facilityId in facilityIds.Distinct()) + { + _context.CustomerPlanFacilities.Add(new CustomerPlanFacility + { + CustomerPlanFacilityId = Guid.NewGuid(), + FKCustomerPlanId = customerPlanId, + FKFacilityId = facilityId + }); + } + } + + public Task> GetFacilityIdsForPlanAsync(Guid customerPlanId) + => _context.CustomerPlanFacilities + .Where(pf => pf.FKCustomerPlanId == customerPlanId) + .Select(pf => pf.FKFacilityId) + .ToListAsync(); + + public async Task>> GetFacilityIdsByPlanIdsAsync(List customerPlanIds) + { + var rows = await _context.CustomerPlanFacilities + .Where(pf => customerPlanIds.Contains(pf.FKCustomerPlanId)) + .ToListAsync(); + + return rows + .GroupBy(pf => pf.FKCustomerPlanId) + .ToDictionary(g => g.Key, g => g.Select(pf => pf.FKFacilityId).ToList()); + } + + // --- AdHoc booking support (new) --- + + public Task GetOverlappingAdhocBookingCountAsync(Guid facilityId, DateTime startTime, DateTime endTime, Guid? excludeBookingId = null) + { + var query = _context.Bookings.Where(b => + b.FKFacilityId == facilityId && + b.FKFacilitySessionId == null && // AdHoc bookings only + b.Status != "Cancelled" && b.Status != "NoShow" && b.Status != "Completed" && + b.StartTime < endTime && b.EndTime > startTime); + + if (excludeBookingId.HasValue) + query = query.Where(b => b.BookingId != excludeBookingId.Value); + + return query.CountAsync(); + } + + public async Task LockFacilityForUpdateAsync(Guid facilityId) + { + // FromSqlInterpolated (not ExecuteSqlInterpolatedAsync, which is for non-query DML) + // is the correct EF Core idiom for a locking SELECT -- it runs through the same ADO + // connection/transaction as the caller's already-open _uow, so the row lock is held + // until that transaction commits/rolls back. AsNoTracking avoids clashing with any + // already-tracked Facility instance in this same context/request. + await _context.Facilities + .FromSqlInterpolated($"SELECT * FROM Facilities WHERE FacilityId = {facilityId} FOR UPDATE") + .AsNoTracking() + .ToListAsync(); + } } } diff --git a/Repos/PlaceRepo.cs b/Repos/PlaceRepo.cs deleted file mode 100644 index 10998e9..0000000 --- a/Repos/PlaceRepo.cs +++ /dev/null @@ -1,143 +0,0 @@ -using Fitness_core_BE.Interfaces; -using Fitness_core_BE.Models; -using Microsoft.EntityFrameworkCore; - -namespace Fitness_core_BE.Repos -{ - public class PlaceRepo : IPlaceRepo - { - private readonly TenantDbContext _context; - - public PlaceRepo(TenantDbContext context) - { - _context = context; - } - - public async Task> GetAllPlaces(Guid clientId, bool includeInactive = false) - { - var query = _context.Places.Where(p => p.FKClientId == clientId); - if (!includeInactive) - query = query.Where(p => p.IsActive); - - return await query.OrderBy(p => p.Name).ToListAsync(); - } - - public Task GetPlaceById(Guid placeId) - => _context.Places.FirstOrDefaultAsync(p => p.PlaceId == placeId); - - public async Task CreatePlace(Place place) - { - place.PlaceId = Guid.NewGuid(); - place.CreatedAt = DateTime.UtcNow; - place.IsActive = true; - - _context.Places.Add(place); - return place; - } - - public async Task UpdatePlace(Place place) - { - var existing = await _context.Places.FindAsync(place.PlaceId) - ?? throw new KeyNotFoundException("Place not found"); - - existing.Name = place.Name; - existing.Description = place.Description; - existing.IsActive = place.IsActive; - - return existing; - } - - public async Task SetPlaceActive(Guid placeId, Guid clientId, bool isActive) - { - var place = await _context.Places - .FirstOrDefaultAsync(p => p.PlaceId == placeId && p.FKClientId == clientId); - if (place == null) return null; - - place.IsActive = isActive; - return place; - } - - public Task GetDeviceByIdForClientAsync(Guid deviceId, Guid clientId) - => _context.AccessDevices - .FirstOrDefaultAsync(d => d.DeviceId == deviceId && d.FKClientId == clientId); - - public async Task> GetPlaceNamesByIdsAsync(List placeIds) - { - return await _context.Places - .Where(p => placeIds.Contains(p.PlaceId)) - .ToDictionaryAsync(p => p.PlaceId, p => p.Name); - } - - public async Task> GetDevicesByPlaceIdsAsync(List placeIds) - { - var devices = await _context.AccessDevices - .Where(d => d.FKPlaceId != null && placeIds.Contains(d.FKPlaceId.Value)) - .ToListAsync(); - - return devices.ToDictionary(d => d.FKPlaceId!.Value, d => d); - } - - public Task IsDeviceAssignedToAnotherPlaceAsync(Guid deviceId, Guid placeId) - => _context.AccessDevices - .AnyAsync(d => d.DeviceId == deviceId && d.FKPlaceId != null && d.FKPlaceId != placeId); - - public async Task AssignDeviceAsync(Guid placeId, Guid deviceId) - { - // A device can only sit in one place at a time -- clear anywhere else it's - // currently assigned before pointing it at this place. - var previouslyAssigned = await _context.AccessDevices - .Where(d => d.FKPlaceId == placeId && d.DeviceId != deviceId) - .ToListAsync(); - foreach (var device in previouslyAssigned) - device.FKPlaceId = null; - - var target = await _context.AccessDevices.FindAsync(deviceId) - ?? throw new KeyNotFoundException("Device not found"); - target.FKPlaceId = placeId; - } - - public async Task UnassignDeviceAsync(Guid placeId) - { - var devices = await _context.AccessDevices - .Where(d => d.FKPlaceId == placeId) - .ToListAsync(); - foreach (var device in devices) - device.FKPlaceId = null; - } - - public async Task SetPlanPlacesAsync(Guid customerPlanId, List placeIds) - { - var existing = await _context.CustomerPlanPlaces - .Where(pp => pp.FKCustomerPlanId == customerPlanId) - .ToListAsync(); - _context.CustomerPlanPlaces.RemoveRange(existing); - - foreach (var placeId in placeIds.Distinct()) - { - _context.CustomerPlanPlaces.Add(new CustomerPlanPlace - { - CustomerPlanPlaceId = Guid.NewGuid(), - FKCustomerPlanId = customerPlanId, - FKPlaceId = placeId - }); - } - } - - public Task> GetPlaceIdsForPlanAsync(Guid customerPlanId) - => _context.CustomerPlanPlaces - .Where(pp => pp.FKCustomerPlanId == customerPlanId) - .Select(pp => pp.FKPlaceId) - .ToListAsync(); - - public async Task>> GetPlaceIdsByPlanIdsAsync(List customerPlanIds) - { - var rows = await _context.CustomerPlanPlaces - .Where(pp => customerPlanIds.Contains(pp.FKCustomerPlanId)) - .ToListAsync(); - - return rows - .GroupBy(pp => pp.FKCustomerPlanId) - .ToDictionary(g => g.Key, g => g.Select(pp => pp.FKPlaceId).ToList()); - } - } -} diff --git a/Repos/RfidTagRepository.cs b/Repos/RfidTagRepository.cs new file mode 100644 index 0000000..8c76a34 --- /dev/null +++ b/Repos/RfidTagRepository.cs @@ -0,0 +1,67 @@ +using Fitness_core_BE.Interfaces; +using Fitness_core_BE.Models; +using Microsoft.EntityFrameworkCore; + +namespace Fitness_core_BE.Repos +{ + public class RfidTagRepository(TenantDbContext context) : IRfidTagRepository + { + private readonly TenantDbContext _context = context; + + public async Task> GetAllTags(Guid clientId, string? status = null) + { + var query = _context.RfidTags.Where(t => t.FKClientId == clientId); + if (!string.IsNullOrEmpty(status)) + query = query.Where(t => t.Status == status); + + return await query.OrderBy(t => t.TagCode).ToListAsync(); + } + + public Task GetTagById(Guid tagId, Guid clientId) + => _context.RfidTags.FirstOrDefaultAsync(t => t.RfidTagId == tagId && t.FKClientId == clientId); + + public Task TagCodeExistsAsync(Guid clientId, string tagCode, Guid? excludeTagId = null) + { + var query = _context.RfidTags.Where(t => t.FKClientId == clientId && t.TagCode == tagCode); + if (excludeTagId.HasValue) + query = query.Where(t => t.RfidTagId != excludeTagId.Value); + return query.AnyAsync(); + } + + public async Task CreateTag(RfidTag tag) + { + tag.RfidTagId = Guid.NewGuid(); + tag.Status = "Available"; + tag.CreatedAt = DateTime.UtcNow; + tag.UpdatedAt = DateTime.UtcNow; + + _context.RfidTags.Add(tag); + return tag; + } + + public async Task UpdateTag(RfidTag tag) + { + var existing = await _context.RfidTags.FindAsync(tag.RfidTagId) + ?? throw new KeyNotFoundException("RFID tag not found"); + + existing.TagCode = tag.TagCode; + existing.Label = tag.Label; + existing.UpdatedAt = DateTime.UtcNow; + + return existing; + } + + public async Task SetStatusAsync(Guid tagId, Guid clientId, string status) + { + var tag = await _context.RfidTags.FirstOrDefaultAsync(t => t.RfidTagId == tagId && t.FKClientId == clientId); + if (tag == null) return null; + + if (tag.Status == "InUse") + throw new ArgumentException("This tag is currently assigned to a booking -- it can only be freed via that booking's Complete action."); + + tag.Status = status; + tag.UpdatedAt = DateTime.UtcNow; + return tag; + } + } +} diff --git a/Services/BookingService.cs b/Services/BookingService.cs index 242c57c..e591dc6 100644 --- a/Services/BookingService.cs +++ b/Services/BookingService.cs @@ -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 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 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 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; + } + } + + /// 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. + 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 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 } } + /// 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. + 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 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 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 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 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 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 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 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 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 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 }; } diff --git a/Services/CustomerPlanService.cs b/Services/CustomerPlanService.cs index 647067e..9393694 100644 --- a/Services/CustomerPlanService.cs +++ b/Services/CustomerPlanService.cs @@ -9,7 +9,11 @@ using Fitness_core_BE.Helpers; namespace Fitness_core_BE.Services.CustomerPlanService { - public class CustomerPlanService : ICustomerPlanService + public class CustomerPlanService( + ICustomerPlanRepository customerPlanRepository, + IFacilityRepository facilityRepository, + IUnitOfWork uow, + UserRoleHelper userRoleHelper) : ICustomerPlanService { private const string ScopeIndividual = "Individual"; private const string ScopeGroup = "Group"; @@ -20,22 +24,10 @@ namespace Fitness_core_BE.Services.CustomerPlanService private const string BillingPrepaid = "Prepaid"; private const string BillingPostpaid = "Postpaid"; - private readonly ICustomerPlanRepository _customerPlanRepository; - private readonly IPlaceRepo _placeRepo; - private readonly IUnitOfWork _uow; - private readonly UserRoleHelper _userRoleHelper; - - public CustomerPlanService( - ICustomerPlanRepository customerPlanRepository, - IPlaceRepo placeRepo, - IUnitOfWork uow, - UserRoleHelper userRoleHelper) - { - _customerPlanRepository = customerPlanRepository; - _placeRepo = placeRepo; - _uow = uow; - _userRoleHelper = userRoleHelper; - } + private readonly ICustomerPlanRepository _customerPlanRepository = customerPlanRepository; + private readonly IFacilityRepository _facilityRepository = facilityRepository; + private readonly IUnitOfWork _uow = uow; + private readonly UserRoleHelper _userRoleHelper = userRoleHelper; public async Task GetAllCustomerPlans(object payload, HttpContext httpContext) { @@ -58,9 +50,9 @@ namespace Fitness_core_BE.Services.CustomerPlanService var plans = await _customerPlanRepository.GetAllCustomerPlans(includeInactive, clientId); - var placeIdsByPlan = await _placeRepo.GetPlaceIdsByPlanIdsAsync(plans.Select(p => p.CustomerPlanId).ToList()); + var placeIdsByPlan = await _facilityRepository.GetFacilityIdsByPlanIdsAsync(plans.Select(p => p.CustomerPlanId).ToList()); var allPlaceIds = placeIdsByPlan.Values.SelectMany(ids => ids).Distinct().ToList(); - var placeNames = await _placeRepo.GetPlaceNamesByIdsAsync(allPlaceIds); + var placeNames = await _facilityRepository.GetFacilityNamesByIdsAsync(allPlaceIds); return plans.Select(p => { @@ -142,7 +134,7 @@ namespace Fitness_core_BE.Services.CustomerPlanService var placeIds = DataExtractor.GetOptionalGuidList(data, "placeIds"); if (placeIds != null) - await _placeRepo.SetPlanPlacesAsync(result.CustomerPlanId, placeIds); + await _facilityRepository.SetPlanFacilitiesAsync(result.CustomerPlanId, placeIds); await _uow.CommitAsync(); @@ -206,7 +198,7 @@ namespace Fitness_core_BE.Services.CustomerPlanService var placeIds = DataExtractor.GetOptionalGuidList(data, "placeIds"); if (placeIds != null) - await _placeRepo.SetPlanPlacesAsync(result.CustomerPlanId, placeIds); + await _facilityRepository.SetPlanFacilitiesAsync(result.CustomerPlanId, placeIds); await _uow.CommitAsync(); @@ -400,8 +392,8 @@ namespace Fitness_core_BE.Services.CustomerPlanService private async Task MapToDtoWithPlacesAsync(Models.CustomerPlan plan, string? clientName) { - var placeIds = await _placeRepo.GetPlaceIdsForPlanAsync(plan.CustomerPlanId); - var placeNames = await _placeRepo.GetPlaceNamesByIdsAsync(placeIds); + var placeIds = await _facilityRepository.GetFacilityIdsForPlanAsync(plan.CustomerPlanId); + var placeNames = await _facilityRepository.GetFacilityNamesByIdsAsync(placeIds); return MapToDto(plan, clientName, placeIds, placeNames); } diff --git a/Services/FacilityService.cs b/Services/FacilityService.cs index c97c85f..d3bfd21 100644 --- a/Services/FacilityService.cs +++ b/Services/FacilityService.cs @@ -10,7 +10,7 @@ namespace Fitness_core_BE.Services { public class FacilityService : IFacilityService { - private static readonly string[] ValidFacilityTypes = { "Gym", "Pool", "Court", "Class" }; + private static readonly string[] ValidBookingModes = ["Scheduled", "AdHoc"]; private readonly IFacilityRepository _facilityRepository; private readonly IUnitOfWork _uow; @@ -31,7 +31,9 @@ namespace Fitness_core_BE.Services var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); var facilities = await _facilityRepository.GetAllFacilities(clientId, includeInactive); - return facilities.Select(MapToDto).ToList(); + 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 CreateFacility(object payload, HttpContext httpContext) @@ -41,11 +43,22 @@ namespace Fitness_core_BE.Services { var data = DeserializePayload.Deserialize(payload); if (!data.ContainsKey("name")) throw new KeyNotFoundException("name is required"); - if (!data.ContainsKey("facilityType")) throw new KeyNotFoundException("facilityType is required"); - var facilityType = data["facilityType"].GetString() ?? ""; - if (!ValidFacilityTypes.Contains(facilityType)) - throw new ArgumentException($"facilityType must be one of: {string.Join(", ", ValidFacilityTypes)}"); + 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 defaultCapacity = data.ContainsKey("defaultCapacity") ? data["defaultCapacity"].GetInt32() : 1; + if (bookingMode == "Scheduled" && 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) + 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)); @@ -53,18 +66,17 @@ namespace Fitness_core_BE.Services { FKClientId = clientId, Name = data["name"].GetString() ?? "", - FacilityType = facilityType, + BookingMode = bookingMode, Description = data.ContainsKey("description") ? data["description"].GetString() : null, - DefaultCapacity = data.ContainsKey("defaultCapacity") ? data["defaultCapacity"].GetInt32() : 1 + DefaultCapacity = defaultCapacity, + NumberOfUnits = numberOfUnits, + PricePerHour = pricePerHour }; - if (facility.DefaultCapacity < 1) - throw new ArgumentException("defaultCapacity must be at least 1"); - var result = await _facilityRepository.CreateFacility(facility); await _uow.CommitAsync(); - return MapToDto(result); + return MapToDto(result, null); } catch { @@ -86,12 +98,12 @@ 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("facilityType")) + if (data.ContainsKey("bookingMode")) { - var facilityType = data["facilityType"].GetString() ?? existing.FacilityType; - if (!ValidFacilityTypes.Contains(facilityType)) - throw new ArgumentException($"facilityType must be one of: {string.Join(", ", ValidFacilityTypes)}"); - existing.FacilityType = facilityType; + 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; } if (data.ContainsKey("description")) existing.Description = data["description"].GetString(); if (data.ContainsKey("defaultCapacity")) @@ -100,12 +112,25 @@ namespace Fitness_core_BE.Services 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(); - return MapToDto(result); + var devicesByFacility = await _facilityRepository.GetDevicesByFacilityIdsAsync(new List { result.FacilityId }); + return MapToDto(result, devicesByFacility.GetValueOrDefault(result.FacilityId)); } catch { @@ -128,7 +153,7 @@ namespace Fitness_core_BE.Services ?? throw new KeyNotFoundException("Facility not found"); await _uow.CommitAsync(); - return MapToDto(facility); + return MapToDto(facility, null); } catch { @@ -154,6 +179,9 @@ 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"); + var startTime = DateTime.Parse(data["startTime"].GetString()!).ToUniversalTime(); var endTime = DateTime.Parse(data["endTime"].GetString()!).ToUniversalTime(); if (endTime <= startTime) @@ -236,15 +264,127 @@ namespace Fitness_core_BE.Services } } - private static FacilityDto MapToDto(Facility facility) => new() + // --- Device binding (absorbed from PlaceService.AssignDevice, unchanged logic) --- + + public async Task 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 { facilityId }); + return MapToDto(facility, devicesByFacility.GetValueOrDefault(facilityId)); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + // --- Availability (new) --- + + public async Task 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 { 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(); + 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); + + result.Add(new FacilityAvailabilityDto + { + FacilityId = facility.FacilityId, + FacilityName = facility.Name, + BookingMode = facility.BookingMode, + TotalUnits = totalCapacity, + UnitsFree = Math.Max(0, totalCapacity - totalBooked), + PricePerHour = facility.PricePerHour + }); + } + } + + return result; + } + + private static FacilityDto MapToDto(Facility facility, AccessDevice? device) => new() { FacilityId = facility.FacilityId, Name = facility.Name, - FacilityType = facility.FacilityType, + BookingMode = facility.BookingMode, Description = facility.Description, DefaultCapacity = facility.DefaultCapacity, + NumberOfUnits = facility.NumberOfUnits, + PricePerHour = facility.PricePerHour, IsActive = facility.IsActive, - CreatedAt = facility.CreatedAt + CreatedAt = facility.CreatedAt, + DeviceId = device?.DeviceId, + DeviceName = device?.DeviceName }; private static FacilitySessionDto MapToDto(FacilitySession session, string? facilityName = null, int bookedCount = 0) => new() diff --git a/Services/PlaceService.cs b/Services/PlaceService.cs deleted file mode 100644 index d7c6328..0000000 --- a/Services/PlaceService.cs +++ /dev/null @@ -1,175 +0,0 @@ -using Fitness_core_BE.DTOs.Place; -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 PlaceService : IPlaceService - { - private readonly IPlaceRepo _placeRepo; - private readonly IUnitOfWork _uow; - private readonly UserRoleHelper _userRoleHelper; - - public PlaceService(IPlaceRepo placeRepo, IUnitOfWork uow, UserRoleHelper userRoleHelper) - { - _placeRepo = placeRepo; - _uow = uow; - _userRoleHelper = userRoleHelper; - } - - public async Task GetAllPlaces(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 places = await _placeRepo.GetAllPlaces(clientId, includeInactive); - - var devicesByPlace = await _placeRepo.GetDevicesByPlaceIdsAsync(places.Select(p => p.PlaceId).ToList()); - - return places.Select(p => MapToDto(p, devicesByPlace.GetValueOrDefault(p.PlaceId))).ToList(); - } - - public async Task CreatePlace(object payload, HttpContext httpContext) - { - await _uow.BeginAsync(); - try - { - var data = DeserializePayload.Deserialize(payload); - if (!data.ContainsKey("name")) throw new KeyNotFoundException("name is required"); - - var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); - - var place = new Place - { - FKClientId = clientId, - Name = data["name"].GetString() ?? "", - Description = data.ContainsKey("description") ? data["description"].GetString() : null - }; - - var result = await _placeRepo.CreatePlace(place); - await _uow.CommitAsync(); - - return MapToDto(result, null); - } - catch - { - await _uow.RollbackAsync(); - throw; - } - } - - public async Task UpdatePlace(object payload, HttpContext httpContext) - { - await _uow.BeginAsync(); - try - { - var data = DeserializePayload.Deserialize(payload); - if (!data.ContainsKey("placeId")) throw new KeyNotFoundException("placeId is required"); - - var placeId = Guid.Parse(data["placeId"].GetString()!); - var existing = await _placeRepo.GetPlaceById(placeId) - ?? throw new KeyNotFoundException("Place 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(); - - var result = await _placeRepo.UpdatePlace(existing); - await _uow.CommitAsync(); - - var devicesByPlace = await _placeRepo.GetDevicesByPlaceIdsAsync(new List { result.PlaceId }); - return MapToDto(result, devicesByPlace.GetValueOrDefault(result.PlaceId)); - } - catch - { - await _uow.RollbackAsync(); - throw; - } - } - - public async Task SetPlaceStatus(object payload, HttpContext httpContext) - { - await _uow.BeginAsync(); - try - { - var data = DeserializePayload.Deserialize(payload); - var placeId = Guid.Parse(data["placeId"].GetString()!); - var isActive = data["isActive"].GetBoolean(); - - var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); - var place = await _placeRepo.SetPlaceActive(placeId, clientId, isActive) - ?? throw new KeyNotFoundException("Place not found"); - - await _uow.CommitAsync(); - - var devicesByPlace = await _placeRepo.GetDevicesByPlaceIdsAsync(new List { place.PlaceId }); - return MapToDto(place, devicesByPlace.GetValueOrDefault(place.PlaceId)); - } - catch - { - await _uow.RollbackAsync(); - throw; - } - } - - public async Task AssignDevice(object payload, HttpContext httpContext) - { - await _uow.BeginAsync(); - try - { - var data = DeserializePayload.Deserialize(payload); - if (!data.ContainsKey("placeId")) throw new KeyNotFoundException("placeId is required"); - - var placeId = Guid.Parse(data["placeId"].GetString()!); - var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); - - var place = await _placeRepo.GetPlaceById(placeId); - if (place == null || place.FKClientId != clientId) - throw new KeyNotFoundException("Place not found"); - - var hasDeviceId = data.ContainsKey("deviceId") && data["deviceId"].ValueKind != JsonValueKind.Null; - if (!hasDeviceId) - { - await _placeRepo.UnassignDeviceAsync(placeId); - } - else - { - var deviceId = Guid.Parse(data["deviceId"].GetString()!); - var device = await _placeRepo.GetDeviceByIdForClientAsync(deviceId, clientId) - ?? throw new KeyNotFoundException("Device not found"); - - if (await _placeRepo.IsDeviceAssignedToAnotherPlaceAsync(deviceId, placeId)) - throw new ArgumentException("This device is already assigned to another place."); - - await _placeRepo.AssignDeviceAsync(placeId, deviceId); - } - - await _uow.CommitAsync(); - - var devicesByPlace = await _placeRepo.GetDevicesByPlaceIdsAsync(new List { placeId }); - return MapToDto(place, devicesByPlace.GetValueOrDefault(placeId)); - } - catch - { - await _uow.RollbackAsync(); - throw; - } - } - - private static PlaceDto MapToDto(Place place, AccessDevice? device) => new() - { - PlaceId = place.PlaceId, - Name = place.Name, - Description = place.Description, - IsActive = place.IsActive, - CreatedAt = place.CreatedAt, - DeviceId = device?.DeviceId, - DeviceName = device?.DeviceName - }; - } -} diff --git a/Services/RfidTagService.cs b/Services/RfidTagService.cs new file mode 100644 index 0000000..6018e02 --- /dev/null +++ b/Services/RfidTagService.cs @@ -0,0 +1,133 @@ +using Fitness_core_BE.DTOs.RfidTag; +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; + +namespace Fitness_core_BE.Services +{ + public class RfidTagService(IRfidTagRepository rfidTagRepository, IUnitOfWork uow, UserRoleHelper userRoleHelper) : IRfidTagService + { + private static readonly string[] StaffSettableStatuses = ["Available", "Lost", "Retired"]; + + private readonly IRfidTagRepository _rfidTagRepository = rfidTagRepository; + private readonly IUnitOfWork _uow = uow; + private readonly UserRoleHelper _userRoleHelper = userRoleHelper; + + public async Task GetAllTags(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + string? status = data.ContainsKey("status") ? data["status"].GetString() : null; + + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + var tags = await _rfidTagRepository.GetAllTags(clientId, status); + + return tags.Select(MapToDto).ToList(); + } + + public async Task CreateTag(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + if (!data.ContainsKey("tagCode")) throw new KeyNotFoundException("tagCode is required"); + + var tagCode = data["tagCode"].GetString() ?? throw new ArgumentException("tagCode is required"); + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + + if (await _rfidTagRepository.TagCodeExistsAsync(clientId, tagCode)) + throw new ArgumentException("A tag with this code already exists."); + + var tag = new RfidTag + { + FKClientId = clientId, + TagCode = tagCode, + Label = data.ContainsKey("label") ? data["label"].GetString() : null + }; + + var result = await _rfidTagRepository.CreateTag(tag); + await _uow.CommitAsync(); + + return MapToDto(result); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task UpdateTag(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + if (!data.ContainsKey("tagId")) throw new KeyNotFoundException("tagId is required"); + + var tagId = Guid.Parse(data["tagId"].GetString()!); + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + + var existing = await _rfidTagRepository.GetTagById(tagId, clientId) + ?? throw new KeyNotFoundException("RFID tag not found"); + + if (data.ContainsKey("tagCode")) + { + var tagCode = data["tagCode"].GetString() ?? existing.TagCode; + if (await _rfidTagRepository.TagCodeExistsAsync(clientId, tagCode, tagId)) + throw new ArgumentException("A tag with this code already exists."); + existing.TagCode = tagCode; + } + if (data.ContainsKey("label")) existing.Label = data["label"].GetString(); + + var result = await _rfidTagRepository.UpdateTag(existing); + await _uow.CommitAsync(); + + return MapToDto(result); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + public async Task SetTagStatus(object payload, HttpContext httpContext) + { + await _uow.BeginAsync(); + try + { + var data = DeserializePayload.Deserialize(payload); + var tagId = Guid.Parse(data["tagId"].GetString()!); + var status = data["status"].GetString() ?? throw new ArgumentException("status is required"); + + if (!StaffSettableStatuses.Contains(status)) + throw new ArgumentException($"status must be one of: {string.Join(", ", StaffSettableStatuses)}"); + + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + var tag = await _rfidTagRepository.SetStatusAsync(tagId, clientId, status) + ?? throw new KeyNotFoundException("RFID tag not found"); + + await _uow.CommitAsync(); + return MapToDto(tag); + } + catch + { + await _uow.RollbackAsync(); + throw; + } + } + + private static RfidTagDto MapToDto(RfidTag tag) => new() + { + RfidTagId = tag.RfidTagId, + TagCode = tag.TagCode, + Label = tag.Label, + Status = tag.Status, + CreatedAt = tag.CreatedAt, + UpdatedAt = tag.UpdatedAt + }; + } +} diff --git a/TenantDbContext.cs b/TenantDbContext.cs index 0b492e4..de37bb8 100644 --- a/TenantDbContext.cs +++ b/TenantDbContext.cs @@ -64,16 +64,19 @@ namespace Fitness_core_BE public DbSet CustomerWorkoutExerciseCompletions => Set(); // Multi-facility session booking + RFID time-bank cards (pool/court/class booking, one - // per club since single-tenant -- see Models/Facility.cs and friends). + // per club since single-tenant), and access-zone/device-scoping -- Facility merges what + // used to be two separate concepts (bookable capacity + access zone), see Models/Facility.cs. public DbSet Facilities => Set(); public DbSet FacilitySessions => Set(); public DbSet Bookings => Set(); public DbSet TimeBankCards => Set(); public DbSet TimeBankCardUsageLogs => Set(); + public DbSet CustomerPlanFacilities => Set(); - // Physical access zones (Pool Area, Indoor Gym, ...) -- see Models/Place.cs. - public DbSet Places => Set(); - public DbSet CustomerPlanPlaces => Set(); + // Booking-scoped RFID tag inventory (assigned to a Booking at check-in, released at + // Complete/no-show sweep) -- see Models/RfidTag.cs. Not to be confused with + // TimeBankCard's own, unrelated RfidTagId string field. + public DbSet RfidTags => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -183,12 +186,14 @@ namespace Fitness_core_BE modelBuilder.Entity().Ignore(b => b.Client); modelBuilder.Entity().Ignore(t => t.Client); modelBuilder.Entity().Ignore(l => l.Client); - modelBuilder.Entity().Ignore(p => p.Client); // A session's capacity is enforced by counting non-cancelled bookings against it at // booking time, not by a unique index -- multiple guests/members can share one slot // up to Facility.DefaultCapacity. What must stay unique is a member double-booking - // the same session twice. + // the same session twice. FKFacilitySessionId is null for every AdHoc booking, and + // MySQL/InnoDB never treats two NULLs as duplicates in a unique index, so this index + // is naturally a no-op for AdHoc bookings -- their own overlap-vs-NumberOfUnits check + // lives in BookingRepository.GetOverlappingAdhocBookingCountAsync instead. modelBuilder.Entity() .HasIndex(b => new { b.FKFacilitySessionId, b.FKCustomerId }) .IsUnique() @@ -241,22 +246,37 @@ namespace Fitness_core_BE // same-context FK anymore (same pattern as every other central-pointing scalar). modelBuilder.Entity().Ignore(d => d.Agent); - // One device per place for now -- see AccessDevice.FKPlaceId comment. Lifting - // this to many-devices-per-place later is just dropping this index. + // One device per facility for now -- see AccessDevice.FKFacilityId comment. Lifting + // this to many-devices-per-facility later is just dropping this index. modelBuilder.Entity() - .HasIndex(d => d.FKPlaceId) + .HasIndex(d => d.FKFacilityId) .IsUnique() - .HasFilter("`FKPlaceId` IS NOT NULL"); + .HasFilter("`FKFacilityId` IS NOT NULL"); - modelBuilder.Entity() - .HasIndex(pp => new { pp.FKCustomerPlanId, pp.FKPlaceId }) + modelBuilder.Entity() + .HasIndex(pf => new { pf.FKCustomerPlanId, pf.FKFacilityId }) .IsUnique(); - modelBuilder.Entity() - .HasOne(pp => pp.CustomerPlan).WithMany() - .HasForeignKey(pp => pp.FKCustomerPlanId).OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() - .HasOne(pp => pp.Place).WithMany() - .HasForeignKey(pp => pp.FKPlaceId).OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(pf => pf.CustomerPlan).WithMany() + .HasForeignKey(pf => pf.FKCustomerPlanId).OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasOne(pf => pf.Facility).WithMany() + .HasForeignKey(pf => pf.FKFacilityId).OnDelete(DeleteBehavior.Restrict); + + // RFID tag inventory is per-client; a tag code only needs to be unique within one + // club's pool. + modelBuilder.Entity() + .HasIndex(t => new { t.FKClientId, t.TagCode }) + .IsUnique(); + + // AdHoc bookings share a facility's NumberOfUnits, checked by counting overlapping + // non-cancelled/non-completed bookings at creation/reschedule time (see + // BookingRepository.GetOverlappingAdhocBookingCountAsync) rather than a DB + // constraint -- an exclusion constraint over a time range isn't expressible as a + // simple unique index, and MySQL/Pomelo has no range-overlap index type. This index + // just makes that overlap query efficient. + modelBuilder.Entity() + .HasIndex(b => new { b.FKFacilityId, b.StartTime, b.EndTime }); modelBuilder.Entity() .HasIndex(e => new { e.FKDeviceId, e.DeviceEmployeeNo, e.LinkedAt }); From 6abef835e851e68a62eae3ac9c8880f572362173 Mon Sep 17 00:00:00 2001 From: Haritha Randunu Date: Wed, 30 Sep 2026 11:41:57 +0530 Subject: [PATCH 2/3] Add PricePerHour to booking requests and update validation logic --- Controllers/BookingController.cs | 3 ++- DTOs/Booking/BookingRequests.cs | 5 +++++ Services/BookingService.cs | 21 ++++++++++++++++----- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/Controllers/BookingController.cs b/Controllers/BookingController.cs index 5923f31..d12318f 100644 --- a/Controllers/BookingController.cs +++ b/Controllers/BookingController.cs @@ -78,7 +78,8 @@ namespace Fitness_core_BE.Controllers durationMinutes = request.DurationMinutes, guestName = request.GuestName, guestPhone = request.GuestPhone, - partySize = request.PartySize + partySize = request.PartySize, + pricePerHour = request.PricePerHour }, HttpContext)); // The actual "copy the link and share it" page -- BookingDto.ShareUrl points here. diff --git a/DTOs/Booking/BookingRequests.cs b/DTOs/Booking/BookingRequests.cs index 690d2ae..d81c174 100644 --- a/DTOs/Booking/BookingRequests.cs +++ b/DTOs/Booking/BookingRequests.cs @@ -52,6 +52,11 @@ namespace Fitness_core_BE.DTOs.Booking public string GuestPhone { get; set; } = string.Empty; public int? PartySize { get; set; } + + /// Overrides the facility's own PricePerHour for this booking's charge only -- + /// the FE pre-fills this from the facility but lets staff adjust it. + [Range(typeof(decimal), "0", "79228162514264337593543950335", ErrorMessage = "PricePerHour cannot be negative")] + public decimal? PricePerHour { get; set; } } public class MarkGuestBookingPaidRequest diff --git a/Services/BookingService.cs b/Services/BookingService.cs index e591dc6..3e0acc9 100644 --- a/Services/BookingService.cs +++ b/Services/BookingService.cs @@ -164,7 +164,7 @@ namespace Fitness_core_BE.Services await _uow.BeginAsync(); try { - var (facility, startTime, endTime, partySize) = await ValidateAdhocRequestAsync(payload); + var (facility, startTime, endTime, partySize, _) = await ValidateAdhocRequestAsync(payload); var cusId = await _userRoleHelper.GetCusId(DataExtractor.GetUserIdFromContext(httpContext)); @@ -202,7 +202,7 @@ namespace Fitness_core_BE.Services await _uow.BeginAsync(); try { - var (facility, startTime, endTime, partySize) = await ValidateAdhocRequestAsync(payload); + var (facility, startTime, endTime, partySize, pricePerHour) = await ValidateAdhocRequestAsync(payload); var data = DeserializePayload.Deserialize(payload); var guestName = data["guestName"].GetString() ?? throw new ArgumentException("guestName is required"); @@ -223,7 +223,10 @@ namespace Fitness_core_BE.Services GuestName = guestName, GuestPhone = guestPhone, OtpCode = GenerateOtp(), - AmountCharged = Math.Round(facility.PricePerHour * durationHours, 2), + // Staff can override the facility's default rate per booking (e.g. a discount) + // -- the pre-filled suggestion in the FE comes from facility.PricePerHour, but + // the charge actually uses whatever rate was submitted. + AmountCharged = Math.Round((pricePerHour ?? facility.PricePerHour) * durationHours, 2), PaymentStatus = "Unpaid", Status = "Confirmed" }; @@ -244,7 +247,7 @@ namespace Fitness_core_BE.Services /// 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. - private async Task<(Facility facility, DateTime startTime, DateTime endTime, int? partySize)> ValidateAdhocRequestAsync(object payload) + private async Task<(Facility facility, DateTime startTime, DateTime endTime, int? partySize, decimal? pricePerHour)> ValidateAdhocRequestAsync(object payload) { var data = DeserializePayload.Deserialize(payload); var facilityId = Guid.Parse(data["facilityId"].GetString()!); @@ -257,6 +260,14 @@ namespace Fitness_core_BE.Services ? data["partySize"].GetInt32() : null; + // Optional per-booking rate override -- the FE pre-fills this from the facility's + // own PricePerHour but lets staff adjust it (e.g. a discount) before charging. + decimal? pricePerHour = data.ContainsKey("pricePerHour") && data["pricePerHour"].ValueKind != JsonValueKind.Null + ? data["pricePerHour"].GetDecimal() + : null; + if (pricePerHour.HasValue && pricePerHour.Value < 0) + throw new ArgumentException("pricePerHour cannot be negative"); + var facility = await _facilityRepository.GetFacilityById(facilityId) ?? throw new KeyNotFoundException("Facility not found"); if (facility.BookingMode != "AdHoc") @@ -269,7 +280,7 @@ namespace Fitness_core_BE.Services if (overlapping >= facility.NumberOfUnits) throw new ArgumentException("No units are free for the requested time"); - return (facility, startTime, endTime, partySize); + return (facility, startTime, endTime, partySize, pricePerHour); } // ---------- Payment / check-in / lifecycle ---------- From 51f0021e27e74612cdf1b07cfa4d1e682357f4ce Mon Sep 17 00:00:00 2001 From: Haritha Randunu Date: Thu, 1 Oct 2026 03:46:03 +0530 Subject: [PATCH 3/3] feat: Enhance booking functionality with invoice management and dashboard summary - Added customerId to guest booking requests for better identification. - Implemented new endpoints for managing booking invoices and dashboard summaries. - Created BookingInvoice and BookingDashboardSummary DTOs to support new features. - Updated Booking and BookingInvoice models to include payment method and timestamps. - Enhanced BookingService to handle invoice creation upon payment confirmation. - Modified BookingRepository to support CRUD operations for BookingInvoice. - Updated TenantDbContext to include BookingInvoice DbSet and relevant configurations. - Improved error handling and validation for booking completion and payment processes. --- Controllers/BookingController.cs | 54 ++++++- DTOs/Booking/BookingDashboardSummaryDto.cs | 12 ++ DTOs/Booking/BookingDto.cs | 3 + DTOs/Booking/BookingInvoiceDto.cs | 21 +++ DTOs/Booking/BookingRequests.cs | 23 +++ Helpers/BookingInvoiceHtml.cs | 68 +++++++++ Interfaces/IBookingRepository.cs | 22 ++- Interfaces/ICustomerRepository.cs | 3 + Interfaces/Services/IBookingService.cs | 17 ++- Models/Booking.cs | 26 +++- Models/BookingInvoice.cs | 44 ++++++ Repos/BookingRepository.cs | 53 ++++++- Repos/CustomerRepository.cs | 7 + Repos/ProfitLossRepository.cs | 24 +++ Services/BookingService.cs | 161 ++++++++++++++++++--- TenantDbContext.cs | 8 + 16 files changed, 501 insertions(+), 45 deletions(-) create mode 100644 DTOs/Booking/BookingDashboardSummaryDto.cs create mode 100644 DTOs/Booking/BookingInvoiceDto.cs create mode 100644 Helpers/BookingInvoiceHtml.cs create mode 100644 Models/BookingInvoice.cs diff --git a/Controllers/BookingController.cs b/Controllers/BookingController.cs index d12318f..4b07266 100644 --- a/Controllers/BookingController.cs +++ b/Controllers/BookingController.cs @@ -65,7 +65,8 @@ namespace Fitness_core_BE.Controllers { facilitySessionId = request.FacilitySessionId, guestName = request.GuestName, - guestPhone = request.GuestPhone + guestPhone = request.GuestPhone, + customerId = request.CustomerId }, HttpContext)); [AllowAnonymous] @@ -79,7 +80,8 @@ namespace Fitness_core_BE.Controllers guestName = request.GuestName, guestPhone = request.GuestPhone, partySize = request.PartySize, - pricePerHour = request.PricePerHour + pricePerHour = request.PricePerHour, + customerId = request.CustomerId }, HttpContext)); // The actual "copy the link and share it" page -- BookingDto.ShareUrl points here. @@ -135,7 +137,12 @@ namespace Fitness_core_BE.Controllers [RequiresPermission("bookings")] [HttpPost("api/client/bookings/{id:guid}/mark-paid")] public async Task>> MarkGuestBookingPaid(Guid id, [FromBody] MarkGuestBookingPaidRequest request) - => Response(await _bookingService.MarkGuestBookingPaid(new { bookingId = id, amountCharged = request.AmountCharged }, HttpContext)); + => Response(await _bookingService.MarkGuestBookingPaid(new + { + bookingId = id, + amountCharged = request.AmountCharged, + paymentMethod = request.PaymentMethod + }, HttpContext)); [CustomMiddleware] [TenantResolution] @@ -180,7 +187,44 @@ namespace Fitness_core_BE.Controllers [TenantResolution] [RequiresPermission("bookings")] [HttpPost("api/client/bookings/{id:guid}/complete")] - public async Task>> CompleteBooking(Guid id) - => Response(await _bookingService.CompleteBooking(new { bookingId = id }, HttpContext)); + public async Task>> CompleteBooking(Guid id, [FromBody] CompleteBookingRequest? request) + => Response(await _bookingService.CompleteBooking(new + { + bookingId = id, + markPaid = request?.MarkPaid, + amountCollected = request?.AmountCollected, + paymentMethod = request?.PaymentMethod + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("bookings")] + [HttpGet("api/client/bookings/invoices")] + public async Task>> GetBookingInvoices([FromQuery] DateTime? fromDate, [FromQuery] DateTime? toDate) + => Response(await _bookingService.GetBookingInvoices(new + { + fromDate = fromDate?.ToString("o"), + toDate = toDate?.ToString("o") + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("bookings")] + [HttpGet("api/client/bookings/dashboard-summary")] + public async Task>> GetBookingDashboardSummary() + => Response(await _bookingService.GetBookingDashboardSummary(new { }, HttpContext)); + + // Public, unguessable-GUID receipt page -- same "public but the link IS the credential" + // model as ViewBooking above. + [AllowAnonymous] + [HttpGet("api/public/booking-invoices/{id:guid}/view")] + public async Task ViewBookingInvoice(Guid id) + { + var invoice = await _bookingService.GetBookingInvoiceView(new { bookingInvoiceId = id }) as BookingInvoiceDto; + if (invoice == null) + return Content(BookingInvoiceHtml.NotFound(), "text/html"); + + return Content(BookingInvoiceHtml.Render(invoice), "text/html"); + } } } diff --git a/DTOs/Booking/BookingDashboardSummaryDto.cs b/DTOs/Booking/BookingDashboardSummaryDto.cs new file mode 100644 index 0000000..824dc49 --- /dev/null +++ b/DTOs/Booking/BookingDashboardSummaryDto.cs @@ -0,0 +1,12 @@ +namespace Fitness_core_BE.DTOs.Booking +{ + /// Backs the Bookings page's Dashboard tab stat-card row. + public class BookingDashboardSummaryDto + { + public int BookingsToday { get; set; } + public decimal RevenueToday { get; set; } + public int CheckedInNow { get; set; } + public int UpcomingConfirmed { get; set; } + public decimal OutstandingUnpaid { get; set; } + } +} diff --git a/DTOs/Booking/BookingDto.cs b/DTOs/Booking/BookingDto.cs index 9be538e..1d8e7da 100644 --- a/DTOs/Booking/BookingDto.cs +++ b/DTOs/Booking/BookingDto.cs @@ -37,6 +37,9 @@ namespace Fitness_core_BE.DTOs.Booking public string PaymentStatus { get; set; } = string.Empty; public decimal AmountCharged { get; set; } + /// "Cash" | "Bank". Null until PaymentStatus becomes "Paid". + public string? PaymentMethod { get; set; } + public DateTime? PaidAt { get; set; } public string Status { get; set; } = string.Empty; public DateTime BookedAt { get; set; } public DateTime? CheckedInAt { get; set; } diff --git a/DTOs/Booking/BookingInvoiceDto.cs b/DTOs/Booking/BookingInvoiceDto.cs new file mode 100644 index 0000000..3fcfd69 --- /dev/null +++ b/DTOs/Booking/BookingInvoiceDto.cs @@ -0,0 +1,21 @@ +namespace Fitness_core_BE.DTOs.Booking +{ + /// A booking payment receipt -- deliberately separate from the membership-based + /// CustomerInvoice, listed on its own "Invoices" tab, not mixed into Customer Payments. + public class BookingInvoiceDto + { + public Guid BookingInvoiceId { get; set; } + public Guid BookingId { get; set; } + public decimal Amount { get; set; } + public string PaymentMethod { get; set; } = string.Empty; + public string GuestName { get; set; } = string.Empty; + public string? GuestPhone { get; set; } + public string FacilityName { get; set; } = string.Empty; + public DateTime BookingStartTime { get; set; } + public DateTime BookingEndTime { get; set; } + public DateTime IssuedAt { get; set; } + + /// Public, unauthenticated link to this receipt's printable page. + public string? ShareUrl { get; set; } + } +} diff --git a/DTOs/Booking/BookingRequests.cs b/DTOs/Booking/BookingRequests.cs index d81c174..c52a523 100644 --- a/DTOs/Booking/BookingRequests.cs +++ b/DTOs/Booking/BookingRequests.cs @@ -18,6 +18,11 @@ namespace Fitness_core_BE.DTOs.Booking [Required] public string GuestPhone { get; set; } = string.Empty; + + /// Optional link to an existing registered customer (TenantCustomerLinkId) when + /// staff recognizes the walk-in as an already-registered member -- purely for + /// identification/auto-fill, doesn't change payment/membership handling at all. + public Guid? CustomerId { get; set; } } public class CreateAdhocBookingRequest @@ -57,6 +62,10 @@ namespace Fitness_core_BE.DTOs.Booking /// the FE pre-fills this from the facility but lets staff adjust it. [Range(typeof(decimal), "0", "79228162514264337593543950335", ErrorMessage = "PricePerHour cannot be negative")] public decimal? PricePerHour { get; set; } + + /// Optional link to an existing registered customer -- see + /// CreateGuestBookingRequest.CustomerId. + public Guid? CustomerId { get; set; } } public class MarkGuestBookingPaidRequest @@ -67,6 +76,20 @@ namespace Fitness_core_BE.DTOs.Booking [Required] [Range(0.01, double.MaxValue, ErrorMessage = "AmountCharged must be greater than zero")] public decimal AmountCharged { get; set; } + + /// "Cash" | "Bank". Defaults to "Cash" if omitted. + public string? PaymentMethod { get; set; } + } + + /// Body for POST .../complete. Only required when the booking is a guest booking + /// still Unpaid -- see BookingService.CompleteBooking. Ignored otherwise (member bookings, + /// or a guest booking already Paid). + public class CompleteBookingRequest + { + public bool? MarkPaid { get; set; } + public decimal? AmountCollected { get; set; } + /// "Cash" | "Bank". Defaults to "Cash" if omitted. + public string? PaymentMethod { get; set; } } public class CheckInBookingRequest diff --git a/Helpers/BookingInvoiceHtml.cs b/Helpers/BookingInvoiceHtml.cs new file mode 100644 index 0000000..eb4f57a --- /dev/null +++ b/Helpers/BookingInvoiceHtml.cs @@ -0,0 +1,68 @@ +using Fitness_core_BE.DTOs.Booking; +using System.Net; + +namespace Fitness_core_BE.Helpers +{ + // Minimal, dependency-free HTML receipt for a booking payment -- mirrors BookingViewHtml.cs's + // "self-contained page, share the link" pattern, kept as its own file since a receipt's + // content (itemized charge, payment method) is different enough from the booking confirmation + // page to not share a template. + public static class BookingInvoiceHtml + { + public static string Render(BookingInvoiceDto invoice) + { + var when = $"{invoice.BookingStartTime:dddd, dd MMM yyyy}, {invoice.BookingStartTime:h:mm tt} - {invoice.BookingEndTime:h:mm tt}"; + + return $$""" + + + + + + Payment Receipt - Vijaya Sport Club + + + +
+

Vijaya Sport Club — Receipt

+
+
Facility{{Encode(invoice.FacilityName)}}
+
Session{{Encode(when)}}
+
Paid by{{Encode(invoice.GuestName)}}
+
Payment Method{{Encode(invoice.PaymentMethod)}}
+
Issued{{invoice.IssuedAt:dd MMM yyyy, h:mm tt}}
+
+
Amount Paid
+
Rs. {{invoice.Amount:N2}}
+
+
+
+ + + """; + } + + public static string NotFound() => """ + + Receipt not found + +

Receipt not found

+

This link may be invalid.

+ + """; + + private static string Encode(string? value) => WebUtility.HtmlEncode(value ?? ""); + } +} diff --git a/Interfaces/IBookingRepository.cs b/Interfaces/IBookingRepository.cs index ec3e4ec..39d3a60 100644 --- a/Interfaces/IBookingRepository.cs +++ b/Interfaces/IBookingRepository.cs @@ -19,21 +19,31 @@ namespace Fitness_core_BE.Interfaces Task> GetBookings(Guid clientId, Guid? facilityId, Guid? facilitySessionId, DateTime? fromUtc, DateTime? toUtc, string? status); // --- Lazy overdue reconciliation (BookingService.SweepOverdueBookingsAsync), run at the - // top of every check-in instead of a background job -- see Booking.Status doc comment. --- + // top of every check-in AND every checkout instead of a background job -- see + // Booking.Status doc comment. --- /// Bulk-flips every Confirmed booking for this client whose EndTime has passed /// to NoShow. Returns the number of rows affected (informational/logging only). Task MarkOverdueConfirmedAsNoShowAsync(Guid clientId, DateTime now); - /// Every CheckedIn booking for this client whose EndTime has passed and hasn't - /// been checked out yet, with its RfidTag eagerly loaded so the caller can complete + - /// release the tag on the same tracked graph in one SaveChanges. - Task> GetOverdueCheckedInBookingsAsync(Guid clientId, DateTime now); - /// Atomically flips a tag from Available to InUse -- the ONE place a real race /// matters (two staff picking the same tag for two different bookings must not both /// win), so this uses a conditional bulk update rather than read-then-write. Returns /// false if the tag was no longer Available. Task TryClaimRfidTagAsync(Guid tagId, DateTime now); + + // --- Booking invoices (snapshot receipts created when a payment is recorded) --- + + Task CreateBookingInvoice(BookingInvoice invoice); + + /// Listed on their own "Invoices" tab -- deliberately not mixed into the + /// membership-based Customer Payments view. + Task> GetBookingInvoices(Guid clientId, DateTime? fromUtc, DateTime? toUtc); + + Task GetBookingInvoiceById(Guid bookingInvoiceId); + + /// Aggregate counts/sums backing the Bookings page's Dashboard tab. + Task<(int bookingsToday, decimal revenueToday, int checkedInNow, int upcomingConfirmed, decimal outstandingUnpaid)> + GetDashboardSummaryAsync(Guid clientId, DateTime now); } } diff --git a/Interfaces/ICustomerRepository.cs b/Interfaces/ICustomerRepository.cs index 8f94b63..6dbc3c2 100644 --- a/Interfaces/ICustomerRepository.cs +++ b/Interfaces/ICustomerRepository.cs @@ -31,6 +31,9 @@ namespace Fitness_core_BE.Interfaces // gym's own schema) -- no clientId parameter needed. Task FindActiveLinkByIdentifierAsync(string identifier); + // Staff picking an existing member to link a walk-in booking to -- identification only. + Task GetTenantCustomerLinkById(Guid id); + Task DeleteCustomer(Guid customerId); // Client-initiated delete: deactivates only this customer's presence at the calling // client's gym (subscriptions/members/plan assignments scoped to clientId). The shared diff --git a/Interfaces/Services/IBookingService.cs b/Interfaces/Services/IBookingService.cs index 40b84f1..6d458c8 100644 --- a/Interfaces/Services/IBookingService.cs +++ b/Interfaces/Services/IBookingService.cs @@ -43,8 +43,10 @@ namespace Fitness_core_BE.Interfaces.Services /// Staff manually flags a Confirmed, never-arrived booking. Task MarkNoShow(object payload, HttpContext httpContext); - /// Staff manually ends a CheckedIn booking early (before the lazy sweep would - /// catch it), releasing its RFID tag if any. + /// Staff checks out a CheckedIn booking, releasing its RFID tag if any. A guest + /// booking still Unpaid must confirm payment in this same call -- see + /// BookingService.CompleteBooking. Also lazily sweeps this client's overdue Confirmed + /// bookings to NoShow first, same as CheckInBooking. Task CompleteBooking(object payload, HttpContext httpContext); Task GetMyBookings(object payload, HttpContext httpContext); @@ -52,5 +54,16 @@ namespace Fitness_core_BE.Interfaces.Services /// Unified roster covering both booking modes. Task GetBookings(object payload, HttpContext httpContext); + + /// Receipts generated whenever a booking's payment is recorded -- their own + /// "Invoices" tab, separate from the membership-based Customer Payments view. + Task GetBookingInvoices(object payload, HttpContext httpContext); + + /// Renders the public, unauthenticated receipt page a BookingInvoice's ShareUrl + /// points to. Returns null if not found. + Task GetBookingInvoiceView(object payload); + + /// Stat-card summary for the Bookings page's Dashboard tab. + Task GetBookingDashboardSummary(object payload, HttpContext httpContext); } } diff --git a/Models/Booking.cs b/Models/Booking.cs index fa174a0..ddb2a39 100644 --- a/Models/Booking.cs +++ b/Models/Booking.cs @@ -86,24 +86,34 @@ namespace Fitness_core_BE.Models public string PaymentStatus { get; set; } = "NotRequired"; public decimal AmountCharged { get; set; } = 0m; + /// "Cash" | "Bank" -- set alongside PaymentStatus becoming "Paid" (MarkGuestBookingPaid + /// or the Complete checkout flow). Null until then. + [MaxLength(20)] + public string? PaymentMethod { get; set; } + /// Set the moment PaymentStatus becomes "Paid" -- distinct from BookedAt/CheckedOutAt, + /// this is specifically when the money was recorded, used to bucket booking revenue into + /// Profit & Loss and to date-stamp the BookingInvoice snapshot. + public DateTime? PaidAt { get; set; } + // Set only once payment is confirmed for a pay-per-session guest booking, or // immediately for a member session (no payment gate) -- see PaymentStatus. public Guid? FKPaymentTransactionId { get; set; } [ForeignKey(nameof(FKPaymentTransactionId))] public PaymentTransaction? PaymentTransaction { get; set; } - /// "Confirmed" | "CheckedIn" | "Cancelled" | "NoShow" | "Completed". NoShow and - /// Completed are reached either by explicit staff action (MarkNoShow/Complete) or lazily, - /// swept by BookingService.SweepOverdueBookingsAsync at the top of every check-in call - /// (not a background job -- overdue bookings are reconciled the next time any check-in - /// happens for this client, across every facility). + /// "Confirmed" | "CheckedIn" | "Cancelled" | "NoShow" | "Completed". NoShow is + /// reached either by explicit staff action (MarkNoShow) or lazily, swept by + /// BookingService.SweepOverdueBookingsAsync at the top of every check-in or checkout call + /// (not a background job -- overdue Confirmed bookings are reconciled the next time any + /// check-in/checkout happens for this client, across every facility). Completed is + /// reached ONLY by the explicit Complete action -- a guest booking can't reach it while + /// still Unpaid, so there's no automatic path that would check someone out unpaid. public string Status { get; set; } = "Confirmed"; public DateTime BookedAt { get; set; } = DateTime.UtcNow; public DateTime? CheckedInAt { get; set; } - /// Set when the booking reaches Completed, whether via the manual Complete - /// action or the lazy overdue-sweep -- the actual moment it was reconciled, not - /// necessarily EndTime. + /// Set when the booking reaches Completed via the manual Complete action -- the + /// actual moment it was checked out, not necessarily EndTime. public DateTime? CheckedOutAt { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } diff --git a/Models/BookingInvoice.cs b/Models/BookingInvoice.cs new file mode 100644 index 0000000..efb569e --- /dev/null +++ b/Models/BookingInvoice.cs @@ -0,0 +1,44 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + /// A snapshot receipt generated the moment a Booking's payment is recorded (either + /// via MarkGuestBookingPaid or the Complete checkout flow) -- deliberately NOT a live join to + /// Booking/Facility, so it stays stable and printable even if the booking is later reassigned, + /// rescheduled, or the facility renamed. Purpose-built for bookings and kept entirely separate + /// from CustomerInvoice, which is hardwired to the membership/subscription domain + /// (FKCustomerSubscriptionId is required there) and has no concept of a one-off booking + /// charge. + public class BookingInvoice + { + [Key] + public Guid BookingInvoiceId { get; set; } + + public Guid FKBookingId { get; set; } + [ForeignKey(nameof(FKBookingId))] + public Booking? Booking { get; set; } + + public Guid FKClientId { get; set; } + [ForeignKey(nameof(FKClientId))] + public Client Client { get; set; } + + public decimal Amount { get; set; } + + /// "Cash" | "Bank". + [MaxLength(20)] + public string PaymentMethod { get; set; } = "Cash"; + + [MaxLength(200)] + public string GuestName { get; set; } = string.Empty; + [MaxLength(32)] + public string? GuestPhone { get; set; } + + [MaxLength(128)] + public string FacilityName { get; set; } = string.Empty; + public DateTime BookingStartTime { get; set; } + public DateTime BookingEndTime { get; set; } + + public DateTime IssuedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Repos/BookingRepository.cs b/Repos/BookingRepository.cs index 146a3ce..4fa4e30 100644 --- a/Repos/BookingRepository.cs +++ b/Repos/BookingRepository.cs @@ -81,12 +81,6 @@ namespace Fitness_core_BE.Repos .Where(b => b.FKClientId == clientId && b.Status == "Confirmed" && b.EndTime < now) .ExecuteUpdateAsync(s => s.SetProperty(b => b.Status, "NoShow")); - public Task> GetOverdueCheckedInBookingsAsync(Guid clientId, DateTime now) - => _context.Bookings - .Include(b => b.RfidTag) - .Where(b => b.FKClientId == clientId && b.Status == "CheckedIn" && b.EndTime < now) - .ToListAsync(); - public async Task TryClaimRfidTagAsync(Guid tagId, DateTime now) { var rows = await _context.RfidTags @@ -96,5 +90,52 @@ namespace Fitness_core_BE.Repos .SetProperty(t => t.UpdatedAt, now)); return rows > 0; } + + public async Task CreateBookingInvoice(BookingInvoice invoice) + { + invoice.BookingInvoiceId = Guid.NewGuid(); + invoice.IssuedAt = DateTime.UtcNow; + + _context.BookingInvoices.Add(invoice); + return invoice; + } + + public Task> GetBookingInvoices(Guid clientId, DateTime? fromUtc, DateTime? toUtc) + { + var query = _context.BookingInvoices.Where(i => i.FKClientId == clientId); + if (fromUtc.HasValue) query = query.Where(i => i.IssuedAt >= fromUtc.Value); + if (toUtc.HasValue) query = query.Where(i => i.IssuedAt <= toUtc.Value); + + return query.OrderByDescending(i => i.IssuedAt).ToListAsync(); + } + + public Task GetBookingInvoiceById(Guid bookingInvoiceId) + => _context.BookingInvoices.FirstOrDefaultAsync(i => i.BookingInvoiceId == bookingInvoiceId); + + public async Task<(int bookingsToday, decimal revenueToday, int checkedInNow, int upcomingConfirmed, decimal outstandingUnpaid)> + GetDashboardSummaryAsync(Guid clientId, DateTime now) + { + var todayStart = now.Date; + var todayEnd = todayStart.AddDays(1); + + var bookingsToday = await _context.Bookings + .CountAsync(b => b.FKClientId == clientId && b.StartTime >= todayStart && b.StartTime < todayEnd); + + var revenueToday = await _context.Bookings + .Where(b => b.FKClientId == clientId && b.PaidAt >= todayStart && b.PaidAt < todayEnd) + .SumAsync(b => (decimal?)b.AmountCharged) ?? 0m; + + var checkedInNow = await _context.Bookings + .CountAsync(b => b.FKClientId == clientId && b.Status == "CheckedIn"); + + var upcomingConfirmed = await _context.Bookings + .CountAsync(b => b.FKClientId == clientId && b.Status == "Confirmed" && b.StartTime > now); + + var outstandingUnpaid = await _context.Bookings + .Where(b => b.FKClientId == clientId && b.PaymentStatus == "Unpaid") + .SumAsync(b => (decimal?)b.AmountCharged) ?? 0m; + + return (bookingsToday, revenueToday, checkedInNow, upcomingConfirmed, outstandingUnpaid); + } } } diff --git a/Repos/CustomerRepository.cs b/Repos/CustomerRepository.cs index 0b0cf5a..3f13ac6 100644 --- a/Repos/CustomerRepository.cs +++ b/Repos/CustomerRepository.cs @@ -115,6 +115,13 @@ namespace Fitness_core_BE.Repos (l.PrimaryPhoneCached == id || l.EmailCached == id)); } + // Staff picking an existing member to link a walk-in booking to (identification only, + // see BookingService.CreateGuestBooking/CreateGuestAdhocBooking) -- tenant-scoped by + // construction, same as FindActiveLinkByIdentifierAsync above. + public Task GetTenantCustomerLinkById(Guid id) + => _context.TenantCustomerLink + .FirstOrDefaultAsync(l => l.TenantCustomerLinkId == id && l.IsActiveAtThisClient); + public async Task UpdateCustomer(Guid customerId, Customer updatedCustomer) { diff --git a/Repos/ProfitLossRepository.cs b/Repos/ProfitLossRepository.cs index 0bdc0ff..032b6d1 100644 --- a/Repos/ProfitLossRepository.cs +++ b/Repos/ProfitLossRepository.cs @@ -82,8 +82,32 @@ namespace Fitness_core_BE.Repos }) .ToListAsync(ct); + // Paid facility bookings (guest/walk-in) -- a plain Booking read, not a + // PaymentTransaction, since Booking has no invoice at all (see BookingInvoice, kept + // deliberately separate from CustomerInvoice/PaymentTransaction's membership-only + // wiring). PaidAt is the recognition date, not BookedAt/CheckedOutAt. + var bookingPayments = await _context.Bookings + .AsNoTracking() + .Where(booking => + booking.FKClientId == clientId && + booking.PaymentStatus == "Paid" && + booking.PaidAt != null && + booking.PaidAt >= fromUtc && + booking.PaidAt < toUtc) + .Select(booking => new LedgerEntryDto + { + EntryType = "Income", + Category = "Facility Bookings", + Description = booking.GuestName, + Amount = booking.AmountCharged, + EntryDate = booking.PaidAt!.Value, + IsManual = false + }) + .ToListAsync(ct); + memberPayments.AddRange(platformInvoices); memberPayments.AddRange(staffPayments); + memberPayments.AddRange(bookingPayments); return memberPayments; } diff --git a/Services/BookingService.cs b/Services/BookingService.cs index 3e0acc9..0ea8561 100644 --- a/Services/BookingService.cs +++ b/Services/BookingService.cs @@ -14,6 +14,7 @@ namespace Fitness_core_BE.Services IFacilityRepository facilityRepository, IRfidTagRepository rfidTagRepository, ICustomerSubscriptionRepository customerSubscriptionRepository, + ICustomerRepository customerRepository, IUnitOfWork uow, UserRoleHelper userRoleHelper) : IBookingService { @@ -21,6 +22,7 @@ namespace Fitness_core_BE.Services private readonly IFacilityRepository _facilityRepository = facilityRepository; private readonly IRfidTagRepository _rfidTagRepository = rfidTagRepository; private readonly ICustomerSubscriptionRepository _customerSubscriptionRepository = customerSubscriptionRepository; + private readonly ICustomerRepository _customerRepository = customerRepository; private readonly IUnitOfWork _uow = uow; private readonly UserRoleHelper _userRoleHelper = userRoleHelper; @@ -113,6 +115,7 @@ namespace Fitness_core_BE.Services var facilitySessionId = Guid.Parse(data["facilitySessionId"].GetString()!); var guestName = data["guestName"].GetString() ?? throw new ArgumentException("guestName is required"); var guestPhone = data["guestPhone"].GetString() ?? throw new ArgumentException("guestPhone is required"); + var customerId = await ResolveOptionalCustomerLinkAsync(data); var session = await _facilityRepository.GetFacilitySessionById(facilitySessionId) ?? throw new KeyNotFoundException("Facility session not found"); @@ -135,6 +138,7 @@ namespace Fitness_core_BE.Services EndTime = session.EndTime, FKClientId = session.FKClientId, IsGuest = true, + FKCustomerId = customerId, GuestName = guestName, GuestPhone = guestPhone, OtpCode = GenerateOtp(), @@ -207,6 +211,7 @@ namespace Fitness_core_BE.Services 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 customerId = await ResolveOptionalCustomerLinkAsync(data); var durationHours = (decimal)(endTime - startTime).TotalHours; @@ -220,6 +225,7 @@ namespace Fitness_core_BE.Services PartySize = partySize, FKClientId = facility.FKClientId, IsGuest = true, + FKCustomerId = customerId, GuestName = guestName, GuestPhone = guestPhone, OtpCode = GenerateOtp(), @@ -283,6 +289,22 @@ namespace Fitness_core_BE.Services return (facility, startTime, endTime, partySize, pricePerHour); } + /// 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 + /// any membership handling; FKCustomerId is just an informational link layered on top of + /// an otherwise-ordinary guest booking. + private async Task ResolveOptionalCustomerLinkAsync(Dictionary data) + { + if (!data.ContainsKey("customerId") || data["customerId"].ValueKind == JsonValueKind.Null) + return null; + + var customerId = Guid.Parse(data["customerId"].GetString()!); + _ = await _customerRepository.GetTenantCustomerLinkById(customerId) + ?? throw new KeyNotFoundException("Customer not found"); + return customerId; + } + // ---------- Payment / check-in / lifecycle ---------- public async Task MarkGuestBookingPaid(object payload, HttpContext httpContext) @@ -293,6 +315,8 @@ namespace Fitness_core_BE.Services var data = DeserializePayload.Deserialize(payload); var bookingId = Guid.Parse(data["bookingId"].GetString()!); var amountCharged = data["amountCharged"].GetDecimal(); + var paymentMethod = data.ContainsKey("paymentMethod") && data["paymentMethod"].ValueKind != JsonValueKind.Null + ? data["paymentMethod"].GetString() ?? "Cash" : "Cash"; var booking = await _bookingRepository.GetBookingById(bookingId) ?? throw new KeyNotFoundException("Booking not found"); @@ -301,6 +325,9 @@ namespace Fitness_core_BE.Services booking.PaymentStatus = "Paid"; booking.AmountCharged = amountCharged; + booking.PaymentMethod = paymentMethod; + booking.PaidAt = DateTime.UtcNow; + await CreateBookingInvoiceAsync(booking, paymentMethod); await _uow.CommitAsync(); return MapToDto(booking, includeOtp: false); @@ -370,29 +397,40 @@ namespace Fitness_core_BE.Services } } - /// 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. + /// Lazily reconciles what a background job would otherwise have swept: Confirmed + /// bookings whose window passed with no check-in become NoShow. Runs at the top of every + /// check-in AND every checkout (Complete), across every facility for this client (not + /// scoped to whichever facility is being acted on right now), so the whole roster stays + /// accurate as long as staff keep interacting with bookings anywhere in the club. + /// Deliberately does NOT auto-complete overdue CheckedIn bookings -- a guest booking can't + /// be checked out while still Unpaid (see CompleteBooking), and nothing here can ask + /// whether payment was collected, so those stay CheckedIn (tag stays InUse) until a staff + /// member manually completes them. private async Task SweepOverdueBookingsAsync(Guid clientId, DateTime now) { await _bookingRepository.MarkOverdueConfirmedAsNoShowAsync(clientId, now); + // Caller's _uow.CommitAsync() persists this alongside its own changes. + } - var overdueCheckedIn = await _bookingRepository.GetOverdueCheckedInBookingsAsync(clientId, now); - foreach (var booking in overdueCheckedIn) + /// Snapshots a receipt the moment a booking's payment is recorded (from + /// MarkGuestBookingPaid or the Complete checkout flow) -- a copy of the booking's numbers + /// at that instant, not a live join, so it stays accurate even if the booking is later + /// reassigned/rescheduled. Caller's _uow.CommitAsync() persists this alongside its own + /// changes. + private async Task CreateBookingInvoiceAsync(Booking booking, string paymentMethod) + { + await _bookingRepository.CreateBookingInvoice(new BookingInvoice { - 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. + FKBookingId = booking.BookingId, + FKClientId = booking.FKClientId, + Amount = booking.AmountCharged, + PaymentMethod = paymentMethod, + GuestName = booking.GuestName ?? string.Empty, + GuestPhone = booking.GuestPhone, + FacilityName = booking.Facility?.Name ?? string.Empty, + BookingStartTime = booking.StartTime, + BookingEndTime = booking.EndTime + }); } public async Task CancelBooking(object payload, HttpContext httpContext) @@ -572,6 +610,9 @@ namespace Fitness_core_BE.Services await _uow.BeginAsync(); try { + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + await SweepOverdueBookingsAsync(clientId, DateTime.UtcNow); + var data = DeserializePayload.Deserialize(payload); var bookingId = Guid.Parse(data["bookingId"].GetString()!); @@ -580,6 +621,28 @@ namespace Fitness_core_BE.Services if (booking.Status != "CheckedIn") throw new ArgumentException("Only a CheckedIn booking can be completed"); + // A guest booking can't be checked out while still Unpaid -- payment must be + // confirmed in this same call. Member bookings (never Unpaid) and guest bookings + // already Paid skip straight through. + if (booking.IsGuest && booking.PaymentStatus == "Unpaid") + { + var markPaid = data.ContainsKey("markPaid") && data["markPaid"].ValueKind != JsonValueKind.Null + && data["markPaid"].GetBoolean(); + if (!markPaid) + throw new ArgumentException("This booking must be paid before it can be checked out"); + + var amountCollected = data.ContainsKey("amountCollected") && data["amountCollected"].ValueKind != JsonValueKind.Null + ? data["amountCollected"].GetDecimal() : (decimal?)null; + var paymentMethod = data.ContainsKey("paymentMethod") && data["paymentMethod"].ValueKind != JsonValueKind.Null + ? data["paymentMethod"].GetString() ?? "Cash" : "Cash"; + + booking.PaymentStatus = "Paid"; + booking.AmountCharged = amountCollected ?? booking.AmountCharged; + booking.PaymentMethod = paymentMethod; + booking.PaidAt = DateTime.UtcNow; + await CreateBookingInvoiceAsync(booking, paymentMethod); + } + var now = DateTime.UtcNow; booking.Status = "Completed"; booking.CheckedOutAt = now; @@ -655,6 +718,48 @@ namespace Fitness_core_BE.Services return MapToDto(booking, includeOtp: !booking.OtpVerifiedAt.HasValue); } + public async Task GetBookingInvoices(object payload, HttpContext httpContext) + { + var data = DeserializePayload.Deserialize(payload); + 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; + + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + var invoices = await _bookingRepository.GetBookingInvoices(clientId, fromUtc, toUtc); + + return invoices.Select(i => MapInvoiceToDto(i, BuildInvoiceShareUrl(httpContext, i.BookingInvoiceId))).ToList(); + } + + public async Task GetBookingInvoiceView(object payload) + { + var data = DeserializePayload.Deserialize(payload); + var bookingInvoiceId = Guid.Parse(data["bookingInvoiceId"].GetString()!); + + var invoice = await _bookingRepository.GetBookingInvoiceById(bookingInvoiceId); + if (invoice == null) + return null; + + return MapInvoiceToDto(invoice, shareUrl: null); + } + + public async Task GetBookingDashboardSummary(object payload, HttpContext httpContext) + { + var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); + var (bookingsToday, revenueToday, checkedInNow, upcomingConfirmed, outstandingUnpaid) = + await _bookingRepository.GetDashboardSummaryAsync(clientId, DateTime.UtcNow); + + return new BookingDashboardSummaryDto + { + BookingsToday = bookingsToday, + RevenueToday = revenueToday, + CheckedInNow = checkedInNow, + UpcomingConfirmed = upcomingConfirmed, + OutstandingUnpaid = outstandingUnpaid + }; + } + private static string GenerateOtp() => RandomNumberGenerator.GetInt32(100000, 1000000).ToString(); // Built from the request itself (scheme/host), not a config value, so the link is @@ -663,6 +768,9 @@ 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 string BuildInvoiceShareUrl(HttpContext httpContext, Guid bookingInvoiceId) + => $"{httpContext.Request.Scheme}://{httpContext.Request.Host}/api/public/booking-invoices/{bookingInvoiceId}/view"; + private static BookingDto MapToDto(Booking booking, bool includeOtp, string? shareUrl = null) => new() { BookingId = booking.BookingId, @@ -685,11 +793,28 @@ namespace Fitness_core_BE.Services RfidReturnedAt = booking.RfidReturnedAt, PaymentStatus = booking.PaymentStatus, AmountCharged = booking.AmountCharged, + PaymentMethod = booking.PaymentMethod, + PaidAt = booking.PaidAt, Status = booking.Status, BookedAt = booking.BookedAt, CheckedInAt = booking.CheckedInAt, CheckedOutAt = booking.CheckedOutAt, ShareUrl = shareUrl }; + + private static BookingInvoiceDto MapInvoiceToDto(BookingInvoice invoice, string? shareUrl) => new() + { + BookingInvoiceId = invoice.BookingInvoiceId, + BookingId = invoice.FKBookingId, + Amount = invoice.Amount, + PaymentMethod = invoice.PaymentMethod, + GuestName = invoice.GuestName, + GuestPhone = invoice.GuestPhone, + FacilityName = invoice.FacilityName, + BookingStartTime = invoice.BookingStartTime, + BookingEndTime = invoice.BookingEndTime, + IssuedAt = invoice.IssuedAt, + ShareUrl = shareUrl + }; } } diff --git a/TenantDbContext.cs b/TenantDbContext.cs index de37bb8..8e37a6c 100644 --- a/TenantDbContext.cs +++ b/TenantDbContext.cs @@ -78,6 +78,10 @@ namespace Fitness_core_BE // TimeBankCard's own, unrelated RfidTagId string field. public DbSet RfidTags => Set(); + // Snapshot receipt created whenever a Booking's payment is recorded -- see + // Models/BookingInvoice.cs. Deliberately separate from CustomerInvoice (membership-only). + public DbSet BookingInvoices => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -184,6 +188,7 @@ namespace Fitness_core_BE modelBuilder.Entity().Ignore(f => f.Client); modelBuilder.Entity().Ignore(s => s.Client); modelBuilder.Entity().Ignore(b => b.Client); + modelBuilder.Entity().Ignore(i => i.Client); modelBuilder.Entity().Ignore(t => t.Client); modelBuilder.Entity().Ignore(l => l.Client); @@ -278,6 +283,9 @@ namespace Fitness_core_BE modelBuilder.Entity() .HasIndex(b => new { b.FKFacilityId, b.StartTime, b.EndTime }); + modelBuilder.Entity() + .HasIndex(i => new { i.FKClientId, i.IssuedAt }); + modelBuilder.Entity() .HasIndex(e => new { e.FKDeviceId, e.DeviceEmployeeNo, e.LinkedAt });