Refactor CustomerPlanService to use FacilityRepository, remove PlaceService, and introduce RfidTagService
- Updated CustomerPlanService to replace references to PlaceRepo with FacilityRepository for managing customer plans and associated facilities. - Removed PlaceService and its related methods, consolidating functionality into FacilityService. - Added RfidTagService for managing RFID tags, including methods for creating, updating, and setting statuses of tags. - Updated TenantDbContext to include new DbSet for RfidTags and CustomerPlanFacilities, and adjusted model configurations accordingly. - Enhanced FacilityService to support new booking modes and validation for facility creation and updates.
This commit is contained in:
@@ -32,6 +32,18 @@ namespace Fitness_core_BE.Controllers
|
|||||||
public async Task<ActionResult<ApiResponse<object>>> CreateMemberBooking([FromBody] CreateMemberBookingRequest request)
|
public async Task<ActionResult<ApiResponse<object>>> CreateMemberBooking([FromBody] CreateMemberBookingRequest request)
|
||||||
=> Response(await _bookingService.CreateMemberBooking(new { facilitySessionId = request.FacilitySessionId }, HttpContext));
|
=> Response(await _bookingService.CreateMemberBooking(new { facilitySessionId = request.FacilitySessionId }, HttpContext));
|
||||||
|
|
||||||
|
[CustomMiddleware]
|
||||||
|
[TenantResolution]
|
||||||
|
[HttpPost("api/app/bookings/adhoc")]
|
||||||
|
public async Task<ActionResult<ApiResponse<object>>> 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]
|
[CustomMiddleware]
|
||||||
[TenantResolution]
|
[TenantResolution]
|
||||||
[HttpGet("api/app/bookings/mine")]
|
[HttpGet("api/app/bookings/mine")]
|
||||||
@@ -56,6 +68,19 @@ namespace Fitness_core_BE.Controllers
|
|||||||
guestPhone = request.GuestPhone
|
guestPhone = request.GuestPhone
|
||||||
}, HttpContext));
|
}, HttpContext));
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpPost("api/public/bookings/guest/adhoc")]
|
||||||
|
public async Task<ActionResult<ApiResponse<object>>> 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.
|
// 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
|
// 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.
|
// 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 ----------
|
// ---------- Staff ----------
|
||||||
|
|
||||||
|
[CustomMiddleware]
|
||||||
|
[TenantResolution]
|
||||||
|
[RequiresPermission("bookings")]
|
||||||
|
[HttpGet("api/client/bookings")]
|
||||||
|
public async Task<ActionResult<ApiResponse<object>>> 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]
|
[CustomMiddleware]
|
||||||
[TenantResolution]
|
[TenantResolution]
|
||||||
[RequiresPermission("bookings")]
|
[RequiresPermission("bookings")]
|
||||||
@@ -84,7 +127,7 @@ namespace Fitness_core_BE.Controllers
|
|||||||
[RequiresPermission("bookings")]
|
[RequiresPermission("bookings")]
|
||||||
[HttpPost("api/client/bookings/check-in")]
|
[HttpPost("api/client/bookings/check-in")]
|
||||||
public async Task<ActionResult<ApiResponse<object>>> CheckInBooking([FromBody] CheckInBookingRequest request)
|
public async Task<ActionResult<ApiResponse<object>>> 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]
|
[CustomMiddleware]
|
||||||
[TenantResolution]
|
[TenantResolution]
|
||||||
@@ -99,5 +142,44 @@ namespace Fitness_core_BE.Controllers
|
|||||||
[HttpPost("api/client/bookings/{id:guid}/cancel")]
|
[HttpPost("api/client/bookings/{id:guid}/cancel")]
|
||||||
public async Task<ActionResult<ApiResponse<object>>> CancelBooking(Guid id)
|
public async Task<ActionResult<ApiResponse<object>>> CancelBooking(Guid id)
|
||||||
=> Response(await _bookingService.CancelBooking(new { bookingId = id }, HttpContext));
|
=> Response(await _bookingService.CancelBooking(new { bookingId = id }, HttpContext));
|
||||||
|
|
||||||
|
[CustomMiddleware]
|
||||||
|
[TenantResolution]
|
||||||
|
[RequiresPermission("bookings")]
|
||||||
|
[HttpPut("api/client/bookings/{id:guid}/reassign")]
|
||||||
|
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> CompleteBooking(Guid id)
|
||||||
|
=> Response(await _bookingService.CompleteBooking(new { bookingId = id }, HttpContext));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,18 +8,15 @@ using static Fitness_core_BE.Helpers.ApiResponseHelper;
|
|||||||
|
|
||||||
namespace Fitness_core_BE.Controllers
|
namespace Fitness_core_BE.Controllers
|
||||||
{
|
{
|
||||||
// Staff/owner-facing facility + facility-session management. Gated on the "bookings" nav
|
// Staff/owner-facing facility (bookable area + access zone -- merged from the former
|
||||||
// permission (NavItem seeded in CentralDbContext.OnModelCreating).
|
// 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]
|
[ApiController]
|
||||||
[Route("api/client/facilities")]
|
[Route("api/client/facilities")]
|
||||||
public class FacilityController : ControllerBase
|
public class FacilityController(IFacilityService facilityService) : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IFacilityService _facilityService;
|
private readonly IFacilityService _facilityService = facilityService;
|
||||||
|
|
||||||
public FacilityController(IFacilityService facilityService)
|
|
||||||
{
|
|
||||||
_facilityService = facilityService;
|
|
||||||
}
|
|
||||||
|
|
||||||
[CustomMiddleware]
|
[CustomMiddleware]
|
||||||
[TenantResolution]
|
[TenantResolution]
|
||||||
@@ -30,28 +27,32 @@ namespace Fitness_core_BE.Controllers
|
|||||||
|
|
||||||
[CustomMiddleware]
|
[CustomMiddleware]
|
||||||
[TenantResolution]
|
[TenantResolution]
|
||||||
[RequiresPermission("bookings")]
|
[RequiresPermission("memberships")]
|
||||||
[HttpPost("")]
|
[HttpPost("")]
|
||||||
public async Task<ActionResult<ApiResponse<object>>> CreateFacility([FromBody] CreateFacilityRequest request)
|
public async Task<ActionResult<ApiResponse<object>>> CreateFacility([FromBody] CreateFacilityRequest request)
|
||||||
=> Response(await _facilityService.CreateFacility(new
|
=> Response(await _facilityService.CreateFacility(new
|
||||||
{
|
{
|
||||||
name = request.Name,
|
name = request.Name,
|
||||||
facilityType = request.FacilityType,
|
bookingMode = request.BookingMode,
|
||||||
description = request.Description,
|
description = request.Description,
|
||||||
defaultCapacity = request.DefaultCapacity
|
defaultCapacity = request.DefaultCapacity,
|
||||||
|
numberOfUnits = request.NumberOfUnits,
|
||||||
|
pricePerHour = request.PricePerHour
|
||||||
}, HttpContext));
|
}, HttpContext));
|
||||||
|
|
||||||
[CustomMiddleware]
|
[CustomMiddleware]
|
||||||
[TenantResolution]
|
[TenantResolution]
|
||||||
[RequiresPermission("bookings")]
|
[RequiresPermission("memberships")]
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
public async Task<ActionResult<ApiResponse<object>>> UpdateFacility(Guid id, [FromBody] UpdateFacilityRequest request)
|
public async Task<ActionResult<ApiResponse<object>>> UpdateFacility(Guid id, [FromBody] UpdateFacilityRequest request)
|
||||||
{
|
{
|
||||||
var payload = new Dictionary<string, object> { ["facilityId"] = id };
|
var payload = new Dictionary<string, object> { ["facilityId"] = id };
|
||||||
if (request.Name != null) payload["name"] = request.Name;
|
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.Description != null) payload["description"] = request.Description;
|
||||||
if (request.DefaultCapacity.HasValue) payload["defaultCapacity"] = request.DefaultCapacity.Value;
|
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;
|
if (request.IsActive.HasValue) payload["isActive"] = request.IsActive.Value;
|
||||||
|
|
||||||
return Response(await _facilityService.UpdateFacility(payload, HttpContext));
|
return Response(await _facilityService.UpdateFacility(payload, HttpContext));
|
||||||
@@ -59,11 +60,18 @@ namespace Fitness_core_BE.Controllers
|
|||||||
|
|
||||||
[CustomMiddleware]
|
[CustomMiddleware]
|
||||||
[TenantResolution]
|
[TenantResolution]
|
||||||
[RequiresPermission("bookings")]
|
[RequiresPermission("memberships")]
|
||||||
[HttpPatch("{id:guid}/status")]
|
[HttpPatch("{id:guid}/status")]
|
||||||
public async Task<ActionResult<ApiResponse<object>>> SetFacilityStatus(Guid id, [FromBody] SetFacilityStatusRequest request)
|
public async Task<ActionResult<ApiResponse<object>>> SetFacilityStatus(Guid id, [FromBody] SetFacilityStatusRequest request)
|
||||||
=> Response(await _facilityService.SetFacilityStatus(new { facilityId = id, isActive = request.IsActive }, HttpContext));
|
=> Response(await _facilityService.SetFacilityStatus(new { facilityId = id, isActive = request.IsActive }, HttpContext));
|
||||||
|
|
||||||
|
[CustomMiddleware]
|
||||||
|
[TenantResolution]
|
||||||
|
[RequiresPermission("memberships")]
|
||||||
|
[HttpPut("{id:guid}/device")]
|
||||||
|
public async Task<ActionResult<ApiResponse<object>>> AssignDevice(Guid id, [FromBody] AssignFacilityDeviceRequest request)
|
||||||
|
=> Response(await _facilityService.AssignDevice(new { facilityId = id, deviceId = request.DeviceId }, HttpContext));
|
||||||
|
|
||||||
[CustomMiddleware]
|
[CustomMiddleware]
|
||||||
[TenantResolution]
|
[TenantResolution]
|
||||||
[RequiresPermission("bookings")]
|
[RequiresPermission("bookings")]
|
||||||
@@ -96,5 +104,18 @@ namespace Fitness_core_BE.Controllers
|
|||||||
[HttpDelete("sessions/{id:guid}")]
|
[HttpDelete("sessions/{id:guid}")]
|
||||||
public async Task<ActionResult<ApiResponse<object>>> CancelFacilitySession(Guid id)
|
public async Task<ActionResult<ApiResponse<object>>> CancelFacilitySession(Guid id)
|
||||||
=> Response(await _facilityService.CancelFacilitySession(new { facilitySessionId = id }, HttpContext));
|
=> Response(await _facilityService.CancelFacilitySession(new { facilitySessionId = id }, HttpContext));
|
||||||
|
|
||||||
|
[CustomMiddleware]
|
||||||
|
[TenantResolution]
|
||||||
|
[RequiresPermission("bookings")]
|
||||||
|
[HttpGet("availability")]
|
||||||
|
public async Task<ActionResult<ApiResponse<object>>> GetAvailability(
|
||||||
|
[FromQuery] Guid? facilityId, [FromQuery] DateTime startTime, [FromQuery] int durationMinutes)
|
||||||
|
=> Response(await _facilityService.GetAvailability(new
|
||||||
|
{
|
||||||
|
facilityId,
|
||||||
|
startTime = startTime.ToString("o"),
|
||||||
|
durationMinutes
|
||||||
|
}, HttpContext));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<ActionResult<ApiResponse<object>>> GetAllPlaces([FromQuery] bool includeInactive = false)
|
|
||||||
=> Response(await _placeService.GetAllPlaces(new { includeInactive }, HttpContext));
|
|
||||||
|
|
||||||
[CustomMiddleware]
|
|
||||||
[TenantResolution]
|
|
||||||
[RequiresPermission("memberships")]
|
|
||||||
[HttpPost("")]
|
|
||||||
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> UpdatePlace(Guid id, [FromBody] UpdatePlaceRequest request)
|
|
||||||
{
|
|
||||||
var payload = new Dictionary<string, object> { ["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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> AssignDevice(Guid id, [FromBody] AssignPlaceDeviceRequest request)
|
|
||||||
=> Response(await _placeService.AssignDevice(new { placeId = id, deviceId = request.DeviceId }, HttpContext));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<ActionResult<ApiResponse<object>>> GetAllTags([FromQuery] string? status)
|
||||||
|
=> Response(await _rfidTagService.GetAllTags(new { status }, HttpContext));
|
||||||
|
|
||||||
|
[CustomMiddleware]
|
||||||
|
[TenantResolution]
|
||||||
|
[RequiresPermission("bookings")]
|
||||||
|
[HttpPost("")]
|
||||||
|
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> UpdateTag(Guid id, [FromBody] UpdateRfidTagRequest request)
|
||||||
|
{
|
||||||
|
var payload = new Dictionary<string, object> { ["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<ActionResult<ApiResponse<object>>> SetTagStatus(Guid id, [FromBody] SetRfidTagStatusRequest request)
|
||||||
|
=> Response(await _rfidTagService.SetTagStatus(new { tagId = id, status = request.Status }, HttpContext));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,22 @@ namespace Fitness_core_BE.DTOs.Booking
|
|||||||
public class BookingDto
|
public class BookingDto
|
||||||
{
|
{
|
||||||
public Guid BookingId { get; set; }
|
public Guid BookingId { get; set; }
|
||||||
public Guid FacilitySessionId { get; set; }
|
|
||||||
|
/// <summary>Null for an AdHoc booking (no pre-created session).</summary>
|
||||||
|
public Guid? FacilitySessionId { get; set; }
|
||||||
|
public Guid FacilityId { get; set; }
|
||||||
public string? FacilityName { get; set; }
|
public string? FacilityName { get; set; }
|
||||||
public DateTime? SessionStartTime { get; set; }
|
|
||||||
public DateTime? SessionEndTime { get; set; }
|
/// <summary>"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).</summary>
|
||||||
|
public string BookingMode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public DateTime StartTime { get; set; }
|
||||||
|
public DateTime EndTime { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Informational headcount, AdHoc only.</summary>
|
||||||
|
public int? PartySize { get; set; }
|
||||||
|
|
||||||
public bool IsGuest { get; set; }
|
public bool IsGuest { get; set; }
|
||||||
public Guid? CustomerId { get; set; }
|
public Guid? CustomerId { get; set; }
|
||||||
@@ -18,11 +30,17 @@ namespace Fitness_core_BE.DTOs.Booking
|
|||||||
public string? OtpCode { get; set; }
|
public string? OtpCode { get; set; }
|
||||||
public bool OtpVerified { 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 string PaymentStatus { get; set; } = string.Empty;
|
||||||
public decimal AmountCharged { get; set; }
|
public decimal AmountCharged { get; set; }
|
||||||
public string Status { get; set; } = string.Empty;
|
public string Status { get; set; } = string.Empty;
|
||||||
public DateTime BookedAt { get; set; }
|
public DateTime BookedAt { get; set; }
|
||||||
public DateTime? CheckedInAt { get; set; }
|
public DateTime? CheckedInAt { get; set; }
|
||||||
|
public DateTime? CheckedOutAt { get; set; }
|
||||||
|
|
||||||
/// <summary>A public, unauthenticated link to this booking's confirmation page --
|
/// <summary>A public, unauthenticated link to this booking's confirmation page --
|
||||||
/// copy-paste-and-share receipt. Works for both member and guest bookings.</summary>
|
/// copy-paste-and-share receipt. Works for both member and guest bookings.</summary>
|
||||||
|
|||||||
@@ -20,6 +20,40 @@ namespace Fitness_core_BE.DTOs.Booking
|
|||||||
public string GuestPhone { get; set; } = string.Empty;
|
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
|
public class MarkGuestBookingPaidRequest
|
||||||
{
|
{
|
||||||
[Required]
|
[Required]
|
||||||
@@ -34,6 +68,11 @@ namespace Fitness_core_BE.DTOs.Booking
|
|||||||
{
|
{
|
||||||
[Required]
|
[Required]
|
||||||
public string OtpCode { get; set; } = string.Empty;
|
public string OtpCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
public Guid? RfidTagId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CancelBookingRequest
|
public class CancelBookingRequest
|
||||||
@@ -41,4 +80,26 @@ namespace Fitness_core_BE.DTOs.Booking
|
|||||||
[Required]
|
[Required]
|
||||||
public Guid BookingId { get; set; }
|
public Guid BookingId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class ReassignBookingRequest
|
||||||
|
{
|
||||||
|
public string? GuestName { get; set; }
|
||||||
|
public string? GuestPhone { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,19 @@ namespace Fitness_core_BE.DTOs.Facility
|
|||||||
{
|
{
|
||||||
public Guid FacilityId { get; set; }
|
public Guid FacilityId { get; set; }
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public string FacilityType { get; set; } = string.Empty;
|
|
||||||
|
/// <summary>"Scheduled" | "AdHoc".</summary>
|
||||||
|
public string BookingMode { get; set; } = "Scheduled";
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
public int DefaultCapacity { get; set; }
|
public int DefaultCapacity { get; set; }
|
||||||
|
public int NumberOfUnits { get; set; }
|
||||||
|
public decimal PricePerHour { get; set; }
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
public DateTime CreatedAt { 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
|
public class FacilitySessionDto
|
||||||
@@ -22,4 +30,17 @@ namespace Fitness_core_BE.DTOs.Facility
|
|||||||
public int BookedCount { get; set; }
|
public int BookedCount { get; set; }
|
||||||
public bool IsCancelled { get; set; }
|
public bool IsCancelled { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>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).</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,22 @@ namespace Fitness_core_BE.DTOs.Facility
|
|||||||
[Required]
|
[Required]
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>"Gym" | "Pool" | "Court" | "Class".</summary>
|
/// <summary>"Scheduled" | "AdHoc".</summary>
|
||||||
[Required]
|
[Required]
|
||||||
public string FacilityType { get; set; } = "Class";
|
public string BookingMode { get; set; } = "Scheduled";
|
||||||
|
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Required (meaningfully) when BookingMode == "Scheduled".</summary>
|
||||||
[Range(1, int.MaxValue, ErrorMessage = "DefaultCapacity must be at least 1")]
|
[Range(1, int.MaxValue, ErrorMessage = "DefaultCapacity must be at least 1")]
|
||||||
public int DefaultCapacity { get; set; } = 1;
|
public int DefaultCapacity { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>Required (meaningfully) when BookingMode == "AdHoc" -- e.g. 3 for "3 courts".</summary>
|
||||||
|
[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
|
public class UpdateFacilityRequest
|
||||||
@@ -22,9 +30,11 @@ namespace Fitness_core_BE.DTOs.Facility
|
|||||||
[Required]
|
[Required]
|
||||||
public Guid FacilityId { get; set; }
|
public Guid FacilityId { get; set; }
|
||||||
public string? Name { get; set; }
|
public string? Name { get; set; }
|
||||||
public string? FacilityType { get; set; }
|
public string? BookingMode { get; set; }
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
public int? DefaultCapacity { get; set; }
|
public int? DefaultCapacity { get; set; }
|
||||||
|
public int? NumberOfUnits { get; set; }
|
||||||
|
public decimal? PricePerHour { get; set; }
|
||||||
public bool? IsActive { get; set; }
|
public bool? IsActive { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,4 +58,22 @@ namespace Fitness_core_BE.DTOs.Facility
|
|||||||
/// <summary>Overrides the facility's default capacity for this specific session.</summary>
|
/// <summary>Overrides the facility's default capacity for this specific session.</summary>
|
||||||
public int? Capacity { get; set; }
|
public int? Capacity { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Absorbed from the old AssignPlaceDeviceRequest.</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
/// <summary>"Available" | "InUse" | "Lost" | "Retired".</summary>
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>"Available" | "Lost" | "Retired" only -- "InUse" is never staff-settable
|
||||||
|
/// directly, only reached via a booking check-in.</summary>
|
||||||
|
[Required]
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,9 +11,7 @@ namespace Fitness_core_BE.Helpers
|
|||||||
public static string Render(BookingDto booking)
|
public static string Render(BookingDto booking)
|
||||||
{
|
{
|
||||||
var who = booking.IsGuest ? Encode(booking.GuestName) : "Member booking";
|
var who = booking.IsGuest ? Encode(booking.GuestName) : "Member booking";
|
||||||
var when = booking.SessionStartTime.HasValue
|
var when = $"{booking.StartTime:dddd, dd MMM yyyy}, {booking.StartTime:h:mm tt} - {booking.EndTime:h:mm tt}";
|
||||||
? $"{booking.SessionStartTime:dddd, dd MMM yyyy}, {booking.SessionStartTime:h:mm tt} - {booking.SessionEndTime:h:mm tt}"
|
|
||||||
: "-";
|
|
||||||
|
|
||||||
var statusColor = booking.Status switch
|
var statusColor = booking.Status switch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,5 +13,27 @@ namespace Fitness_core_BE.Interfaces
|
|||||||
Task<List<Booking>> GetBookingsForSession(Guid facilitySessionId);
|
Task<List<Booking>> GetBookingsForSession(Guid facilitySessionId);
|
||||||
Task<List<Booking>> GetBookingsForCustomer(Guid customerId);
|
Task<List<Booking>> GetBookingsForCustomer(Guid customerId);
|
||||||
Task<bool> CustomerHasActiveBookingForSession(Guid facilitySessionId, Guid customerId);
|
Task<bool> CustomerHasActiveBookingForSession(Guid facilitySessionId, Guid customerId);
|
||||||
|
|
||||||
|
/// <summary>Unified roster query covering both Scheduled and AdHoc bookings -- backs
|
||||||
|
/// GET api/client/bookings. All filters are optional/AND-combined.</summary>
|
||||||
|
Task<List<Booking>> 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. ---
|
||||||
|
|
||||||
|
/// <summary>Bulk-flips every Confirmed booking for this client whose EndTime has passed
|
||||||
|
/// to NoShow. Returns the number of rows affected (informational/logging only).</summary>
|
||||||
|
Task<int> MarkOverdueConfirmedAsNoShowAsync(Guid clientId, DateTime now);
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
Task<List<Booking>> GetOverdueCheckedInBookingsAsync(Guid clientId, DateTime now);
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
Task<bool> TryClaimRfidTagAsync(Guid tagId, DateTime now);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,5 +15,31 @@ namespace Fitness_core_BE.Interfaces
|
|||||||
Task<List<FacilitySession>> GetSessions(Guid clientId, Guid? facilityId, DateTime fromUtc, DateTime toUtc);
|
Task<List<FacilitySession>> GetSessions(Guid clientId, Guid? facilityId, DateTime fromUtc, DateTime toUtc);
|
||||||
Task<int> GetActiveBookingCount(Guid facilitySessionId);
|
Task<int> GetActiveBookingCount(Guid facilitySessionId);
|
||||||
Task<FacilitySession?> CancelFacilitySession(Guid facilitySessionId, Guid clientId);
|
Task<FacilitySession?> CancelFacilitySession(Guid facilitySessionId, Guid clientId);
|
||||||
|
|
||||||
|
// Device binding (absorbed from the old IPlaceRepo -- one AccessDevice per Facility).
|
||||||
|
Task<AccessDevice?> GetDeviceByIdForClientAsync(Guid deviceId, Guid clientId);
|
||||||
|
Task<Dictionary<Guid, AccessDevice>> GetDevicesByFacilityIdsAsync(List<Guid> facilityIds);
|
||||||
|
Task<bool> 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<Guid> facilityIds);
|
||||||
|
Task<List<Guid>> GetFacilityIdsForPlanAsync(Guid customerPlanId);
|
||||||
|
Task<Dictionary<Guid, List<Guid>>> GetFacilityIdsByPlanIdsAsync(List<Guid> customerPlanIds);
|
||||||
|
Task<Dictionary<Guid, string>> GetFacilityNamesByIdsAsync(List<Guid> facilityIds);
|
||||||
|
|
||||||
|
// AdHoc booking support (new). See TenantDbContext's Booking (FKFacilityId, StartTime,
|
||||||
|
// EndTime) index comment -- overlap is checked here rather than by a DB constraint.
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
Task<int> GetOverlappingAdhocBookingCountAsync(Guid facilityId, DateTime startTime, DateTime endTime, Guid? excludeBookingId = null);
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
Task LockFacilityForUpdateAsync(Guid facilityId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
using Fitness_core_BE.Models;
|
|
||||||
|
|
||||||
namespace Fitness_core_BE.Interfaces
|
|
||||||
{
|
|
||||||
public interface IPlaceRepo
|
|
||||||
{
|
|
||||||
Task<List<Place>> GetAllPlaces(Guid clientId, bool includeInactive = false);
|
|
||||||
Task<Place?> GetPlaceById(Guid placeId);
|
|
||||||
Task<Place> CreatePlace(Place place);
|
|
||||||
Task<Place> UpdatePlace(Place place);
|
|
||||||
Task<Place?> SetPlaceActive(Guid placeId, Guid clientId, bool isActive);
|
|
||||||
|
|
||||||
Task<AccessDevice?> GetDeviceByIdForClientAsync(Guid deviceId, Guid clientId);
|
|
||||||
Task<Dictionary<Guid, AccessDevice>> GetDevicesByPlaceIdsAsync(List<Guid> placeIds);
|
|
||||||
Task<bool> 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<Guid> placeIds);
|
|
||||||
Task<List<Guid>> GetPlaceIdsForPlanAsync(Guid customerPlanId);
|
|
||||||
Task<Dictionary<Guid, List<Guid>>> GetPlaceIdsByPlanIdsAsync(List<Guid> customerPlanIds);
|
|
||||||
Task<Dictionary<Guid, string>> GetPlaceNamesByIdsAsync(List<Guid> placeIds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using Fitness_core_BE.Models;
|
||||||
|
|
||||||
|
namespace Fitness_core_BE.Interfaces
|
||||||
|
{
|
||||||
|
public interface IRfidTagRepository
|
||||||
|
{
|
||||||
|
Task<List<RfidTag>> GetAllTags(Guid clientId, string? status = null);
|
||||||
|
Task<RfidTag?> GetTagById(Guid tagId, Guid clientId);
|
||||||
|
Task<bool> TagCodeExistsAsync(Guid clientId, string tagCode, Guid? excludeTagId = null);
|
||||||
|
Task<RfidTag> CreateTag(RfidTag tag);
|
||||||
|
Task<RfidTag> UpdateTag(RfidTag tag);
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
Task<RfidTag?> SetStatusAsync(Guid tagId, Guid clientId, string status);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,13 @@ namespace Fitness_core_BE.Interfaces.Services
|
|||||||
/// <summary>No membership at all -- pay-per-session, identity proof is the returned OTP.</summary>
|
/// <summary>No membership at all -- pay-per-session, identity proof is the returned OTP.</summary>
|
||||||
Task<object> CreateGuestBooking(object payload, HttpContext httpContext);
|
Task<object> CreateGuestBooking(object payload, HttpContext httpContext);
|
||||||
|
|
||||||
|
/// <summary>Member books an AdHoc-mode facility directly for an arbitrary time window, no
|
||||||
|
/// pre-created session.</summary>
|
||||||
|
Task<object> CreateMemberAdhocBooking(object payload, HttpContext httpContext);
|
||||||
|
|
||||||
|
/// <summary>Guest/staff walk-in books an AdHoc-mode facility directly.</summary>
|
||||||
|
Task<object> CreateGuestAdhocBooking(object payload, HttpContext httpContext);
|
||||||
|
|
||||||
/// <summary>Renders the public, unauthenticated confirmation page a booking's ShareUrl
|
/// <summary>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
|
/// 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.</summary>
|
/// the controller can render a 404 page instead of a JSON error for a browser hit.</summary>
|
||||||
@@ -18,11 +25,32 @@ namespace Fitness_core_BE.Interfaces.Services
|
|||||||
/// <summary>Staff records a guest booking's payment (cash/bank/at-venue) after the fact.</summary>
|
/// <summary>Staff records a guest booking's payment (cash/bank/at-venue) after the fact.</summary>
|
||||||
Task<object> MarkGuestBookingPaid(object payload, HttpContext httpContext);
|
Task<object> MarkGuestBookingPaid(object payload, HttpContext httpContext);
|
||||||
|
|
||||||
/// <summary>Staff/door check-in: verifies the OTP and marks the booking checked in.</summary>
|
/// <summary>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.</summary>
|
||||||
Task<object> CheckInBooking(object payload, HttpContext httpContext);
|
Task<object> CheckInBooking(object payload, HttpContext httpContext);
|
||||||
|
|
||||||
Task<object> CancelBooking(object payload, HttpContext httpContext);
|
Task<object> CancelBooking(object payload, HttpContext httpContext);
|
||||||
|
|
||||||
|
/// <summary>Staff hands a Confirmed guest booking to a different person (name/phone) --
|
||||||
|
/// no cancel/recreate needed. Guest bookings only.</summary>
|
||||||
|
Task<object> ReassignBooking(object payload, HttpContext httpContext);
|
||||||
|
|
||||||
|
/// <summary>Staff moves a Confirmed booking to a different time (AdHoc) or session
|
||||||
|
/// (Scheduled), only onto a slot with room.</summary>
|
||||||
|
Task<object> RescheduleBooking(object payload, HttpContext httpContext);
|
||||||
|
|
||||||
|
/// <summary>Staff manually flags a Confirmed, never-arrived booking.</summary>
|
||||||
|
Task<object> MarkNoShow(object payload, HttpContext httpContext);
|
||||||
|
|
||||||
|
/// <summary>Staff manually ends a CheckedIn booking early (before the lazy sweep would
|
||||||
|
/// catch it), releasing its RFID tag if any.</summary>
|
||||||
|
Task<object> CompleteBooking(object payload, HttpContext httpContext);
|
||||||
|
|
||||||
Task<object> GetMyBookings(object payload, HttpContext httpContext);
|
Task<object> GetMyBookings(object payload, HttpContext httpContext);
|
||||||
Task<object> GetSessionBookings(object payload, HttpContext httpContext);
|
Task<object> GetSessionBookings(object payload, HttpContext httpContext);
|
||||||
|
|
||||||
|
/// <summary>Unified roster covering both booking modes.</summary>
|
||||||
|
Task<object> GetBookings(object payload, HttpContext httpContext);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,5 +12,10 @@ namespace Fitness_core_BE.Interfaces.Services
|
|||||||
Task<object> CreateFacilitySession(object payload, HttpContext httpContext);
|
Task<object> CreateFacilitySession(object payload, HttpContext httpContext);
|
||||||
Task<object> GetSessions(object payload, HttpContext httpContext);
|
Task<object> GetSessions(object payload, HttpContext httpContext);
|
||||||
Task<object> CancelFacilitySession(object payload, HttpContext httpContext);
|
Task<object> CancelFacilitySession(object payload, HttpContext httpContext);
|
||||||
|
|
||||||
|
// Absorbed from the old IPlaceService.
|
||||||
|
Task<object> AssignDevice(object payload, HttpContext httpContext);
|
||||||
|
|
||||||
|
Task<object> GetAvailability(object payload, HttpContext httpContext);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
|
|
||||||
namespace Fitness_core_BE.Interfaces.Services
|
|
||||||
{
|
|
||||||
public interface IPlaceService
|
|
||||||
{
|
|
||||||
Task<object> GetAllPlaces(object payload, HttpContext httpContext);
|
|
||||||
Task<object> CreatePlace(object payload, HttpContext httpContext);
|
|
||||||
Task<object> UpdatePlace(object payload, HttpContext httpContext);
|
|
||||||
Task<object> SetPlaceStatus(object payload, HttpContext httpContext);
|
|
||||||
Task<object> AssignDevice(object payload, HttpContext httpContext);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace Fitness_core_BE.Interfaces.Services
|
||||||
|
{
|
||||||
|
public interface IRfidTagService
|
||||||
|
{
|
||||||
|
Task<object> GetAllTags(object payload, HttpContext httpContext);
|
||||||
|
Task<object> CreateTag(object payload, HttpContext httpContext);
|
||||||
|
Task<object> UpdateTag(object payload, HttpContext httpContext);
|
||||||
|
Task<object> SetTagStatus(object payload, HttpContext httpContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,13 +37,13 @@ namespace Fitness_core_BE.Models
|
|||||||
[MaxLength(160)]
|
[MaxLength(160)]
|
||||||
public string? HardwareId { get; set; }
|
public string? HardwareId { get; set; }
|
||||||
|
|
||||||
// Which Place (physical zone) this terminal sits in. Null = unscoped: reachable by
|
// Which Facility (physical zone) this terminal sits in. Null = unscoped: reachable by
|
||||||
// every plan regardless of that plan's Places (keeps pre-existing devices and
|
// every plan regardless of that plan's Facilities (keeps pre-existing devices and
|
||||||
// plans without any place restriction working unchanged). Unique when set --
|
// plans without any facility restriction working unchanged). Unique when set --
|
||||||
// exactly one device per place for now (see Models/Place.cs).
|
// exactly one device per facility for now (see Models/Facility.cs).
|
||||||
public Guid? FKPlaceId { get; set; }
|
public Guid? FKFacilityId { get; set; }
|
||||||
[ForeignKey(nameof(FKPlaceId))]
|
[ForeignKey(nameof(FKFacilityId))]
|
||||||
public Place? Place { get; set; }
|
public Facility? Facility { get; set; }
|
||||||
|
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
public DateTime? LastSeenAt { get; set; }
|
public DateTime? LastSeenAt { get; set; }
|
||||||
|
|||||||
+50
-8
@@ -3,19 +3,53 @@ using System.ComponentModel.DataAnnotations.Schema;
|
|||||||
|
|
||||||
namespace Fitness_core_BE.Models
|
namespace Fitness_core_BE.Models
|
||||||
{
|
{
|
||||||
/// <summary>One reservation against a FacilitySession — either an existing member using a
|
/// <summary>One reservation against a Facility — either a Scheduled-mode booking against a
|
||||||
/// CustomerPlan session allowance (FKCustomerId set, IsGuest = false) or a non-member who
|
/// pre-created FacilitySession (many bookings share one capacity pool), or an AdHoc-mode
|
||||||
/// booked online and pays per-session (IsGuest = true, no membership at all). The guest's
|
/// booking that claims one of the Facility's NumberOfUnits directly for an arbitrary time
|
||||||
/// identity proof for check-in is the OTP issued at booking time, verified at the door
|
/// window, no session needed. Either an existing member using a CustomerPlan session
|
||||||
/// instead of a fingerprint/face profile a non-member will never have.</summary>
|
/// 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.</summary>
|
||||||
public class Booking
|
public class Booking
|
||||||
{
|
{
|
||||||
[Key]
|
[Key]
|
||||||
public Guid BookingId { get; set; }
|
public Guid BookingId { get; set; }
|
||||||
|
|
||||||
public Guid FKFacilitySessionId { get; set; }
|
/// <summary>Null for an AdHoc booking (no pre-created session).</summary>
|
||||||
|
public Guid? FKFacilitySessionId { get; set; }
|
||||||
[ForeignKey(nameof(FKFacilitySessionId))]
|
[ForeignKey(nameof(FKFacilitySessionId))]
|
||||||
public FacilitySession FacilitySession { get; set; }
|
public FacilitySession? FacilitySession { get; set; }
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
public Guid FKFacilityId { get; set; }
|
||||||
|
[ForeignKey(nameof(FKFacilityId))]
|
||||||
|
public Facility Facility { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Copied from FacilitySession.StartTime (Scheduled) or the requested start
|
||||||
|
/// (AdHoc) at creation; updated in place by Reschedule.</summary>
|
||||||
|
public DateTime StartTime { get; set; }
|
||||||
|
/// <summary>Same pattern as StartTime.</summary>
|
||||||
|
public DateTime EndTime { get; set; }
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
public int? PartySize { get; set; }
|
||||||
|
|
||||||
|
/// <summary>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).</summary>
|
||||||
|
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; }
|
public Guid FKClientId { get; set; }
|
||||||
[ForeignKey(nameof(FKClientId))]
|
[ForeignKey(nameof(FKClientId))]
|
||||||
@@ -58,11 +92,19 @@ namespace Fitness_core_BE.Models
|
|||||||
[ForeignKey(nameof(FKPaymentTransactionId))]
|
[ForeignKey(nameof(FKPaymentTransactionId))]
|
||||||
public PaymentTransaction? PaymentTransaction { get; set; }
|
public PaymentTransaction? PaymentTransaction { get; set; }
|
||||||
|
|
||||||
/// <summary>"Confirmed" | "CheckedIn" | "Cancelled" | "NoShow".</summary>
|
/// <summary>"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).</summary>
|
||||||
public string Status { get; set; } = "Confirmed";
|
public string Status { get; set; } = "Confirmed";
|
||||||
|
|
||||||
public DateTime BookedAt { get; set; } = DateTime.UtcNow;
|
public DateTime BookedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime? CheckedInAt { get; set; }
|
public DateTime? CheckedInAt { get; set; }
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
public DateTime? CheckedOutAt { get; set; }
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Fitness_core_BE.Models
|
||||||
|
{
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
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!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
|
|
||||||
namespace Fitness_core_BE.Models
|
|
||||||
{
|
|
||||||
/// <summary>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.</summary>
|
|
||||||
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!;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+25
-8
@@ -3,29 +3,46 @@ using System.ComponentModel.DataAnnotations.Schema;
|
|||||||
|
|
||||||
namespace Fitness_core_BE.Models
|
namespace Fitness_core_BE.Models
|
||||||
{
|
{
|
||||||
/// <summary>A bookable area of the club — the gym floor, the pool, a court, a class type
|
/// <summary>A physical area of the club (e.g. "Pool Area", "Indoor Gym", "Court 1") that can
|
||||||
/// (karate, etc). Gym-floor access itself keeps working through the existing
|
/// both be booked (Booking/FacilitySession below) and fitted with its own fingerprint/face
|
||||||
/// CustomerPlan/Attendance flow unchanged; Facility/FacilitySession/Booking below are for
|
/// terminal for membership-plan access scoping (AccessDevice.FKFacilityId,
|
||||||
/// the new capacity-limited, session-based areas (pool, courts, classes).</summary>
|
/// 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.</summary>
|
||||||
public class Facility
|
public class Facility
|
||||||
{
|
{
|
||||||
[Key]
|
[Key]
|
||||||
public Guid FacilityId { get; set; }
|
public Guid FacilityId { get; set; }
|
||||||
|
|
||||||
public Guid FKClientId { get; set; }
|
public Guid FKClientId { get; set; }
|
||||||
[ForeignKey(nameof(FKClientId))]
|
[ForeignKey(nameof(FKClientId))]
|
||||||
public Client Client { get; set; }
|
public Client Client { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(128)]
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>"Gym" | "Pool" | "Court" | "Class" — informational grouping for the admin
|
/// <summary>"Scheduled" | "AdHoc". Scheduled: staff pre-creates fixed-time
|
||||||
/// UI; booking rules themselves live on FacilitySession/CustomerPlan, not on this type.</summary>
|
/// FacilitySessions that many bookings share one capacity pool against (classes).
|
||||||
public string FacilityType { get; set; } = "Class";
|
/// AdHoc: a booking claims one of NumberOfUnits identical units directly for an
|
||||||
|
/// arbitrary time window, no pre-created session needed (courts/pool).</summary>
|
||||||
|
public string BookingMode { get; set; } = "Scheduled";
|
||||||
|
|
||||||
|
[MaxLength(500)]
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
|
|
||||||
/// <summary>Default per-session headcount limit; a specific FacilitySession can override.</summary>
|
/// <summary>Default per-session headcount limit when BookingMode == "Scheduled"; a
|
||||||
|
/// specific FacilitySession can override.</summary>
|
||||||
public int DefaultCapacity { get; set; } = 1;
|
public int DefaultCapacity { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
public int NumberOfUnits { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>Hourly rate used to compute a guest AdHoc booking's suggested AmountCharged
|
||||||
|
/// (PricePerHour * duration-in-hours) at creation time.</summary>
|
||||||
|
public decimal PricePerHour { get; set; } = 0m;
|
||||||
|
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
|
|
||||||
namespace Fitness_core_BE.Models
|
|
||||||
{
|
|
||||||
/// <summary>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.</summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Fitness_core_BE.Models
|
||||||
|
{
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
public class RfidTag
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public Guid RfidTagId { get; set; }
|
||||||
|
|
||||||
|
public Guid FKClientId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The tag's printed/embedded serial.</summary>
|
||||||
|
[MaxLength(64)]
|
||||||
|
public string TagCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Friendly name, e.g. "Red Band #12".</summary>
|
||||||
|
[MaxLength(100)]
|
||||||
|
public string? Label { get; set; }
|
||||||
|
|
||||||
|
/// <summary>"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.</summary>
|
||||||
|
public string Status { get; set; } = "Available";
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -143,7 +143,7 @@ builder.Services.AddScoped<ICustomerNotificationRepository, CustomerNotification
|
|||||||
builder.Services.AddScoped<IFacilityRepository, FacilityRepository>();
|
builder.Services.AddScoped<IFacilityRepository, FacilityRepository>();
|
||||||
builder.Services.AddScoped<IBookingRepository, BookingRepository>();
|
builder.Services.AddScoped<IBookingRepository, BookingRepository>();
|
||||||
builder.Services.AddScoped<ITimeBankCardRepository, TimeBankCardRepository>();
|
builder.Services.AddScoped<ITimeBankCardRepository, TimeBankCardRepository>();
|
||||||
builder.Services.AddScoped<IPlaceRepo, PlaceRepo>();
|
builder.Services.AddScoped<IRfidTagRepository, RfidTagRepository>();
|
||||||
|
|
||||||
// Register Helpers
|
// Register Helpers
|
||||||
builder.Services.AddScoped<Fitness_core_BE.Infra.Tenancy.ITenantContext, Fitness_core_BE.Infra.Tenancy.TenantContext>();
|
builder.Services.AddScoped<Fitness_core_BE.Infra.Tenancy.ITenantContext, Fitness_core_BE.Infra.Tenancy.TenantContext>();
|
||||||
@@ -163,11 +163,11 @@ builder.Services.AddScoped<ICustomerInvoiceService, CustomerInvoiceService>();
|
|||||||
builder.Services.AddScoped<ICustomerSubscriptionService, CustomerSubscriptionService>();
|
builder.Services.AddScoped<ICustomerSubscriptionService, CustomerSubscriptionService>();
|
||||||
builder.Services.AddScoped<IFacilityService, FacilityService>();
|
builder.Services.AddScoped<IFacilityService, FacilityService>();
|
||||||
builder.Services.AddScoped<IBookingService, BookingService>();
|
builder.Services.AddScoped<IBookingService, BookingService>();
|
||||||
|
builder.Services.AddScoped<IRfidTagService, RfidTagService>();
|
||||||
builder.Services.AddScoped<ITimeBankCardService, TimeBankCardService>();
|
builder.Services.AddScoped<ITimeBankCardService, TimeBankCardService>();
|
||||||
builder.Services.AddScoped<IBootstrapService, BootstrapService>();
|
builder.Services.AddScoped<IBootstrapService, BootstrapService>();
|
||||||
builder.Services.AddScoped<ISubscriptionRenewalApprovalService, SubscriptionRenewalApprovalService>();
|
builder.Services.AddScoped<ISubscriptionRenewalApprovalService, SubscriptionRenewalApprovalService>();
|
||||||
builder.Services.AddScoped<ICustomerPlanService, CustomerPlanService>();
|
builder.Services.AddScoped<ICustomerPlanService, CustomerPlanService>();
|
||||||
builder.Services.AddScoped<IPlaceService, PlaceService>();
|
|
||||||
builder.Services.AddScoped<IMealService, MealService>();
|
builder.Services.AddScoped<IMealService, MealService>();
|
||||||
builder.Services.AddScoped<IWorkoutService, WorkoutService>();
|
builder.Services.AddScoped<IWorkoutService, WorkoutService>();
|
||||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||||
|
|||||||
@@ -24,23 +24,31 @@ namespace Fitness_core_BE.Repos
|
|||||||
|
|
||||||
public Task<Booking?> GetBookingById(Guid bookingId)
|
public Task<Booking?> GetBookingById(Guid bookingId)
|
||||||
=> _context.Bookings
|
=> _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);
|
.FirstOrDefaultAsync(b => b.BookingId == bookingId);
|
||||||
|
|
||||||
public Task<Booking?> GetActiveBookingByOtp(string otpCode)
|
public Task<Booking?> GetActiveBookingByOtp(string otpCode)
|
||||||
=> _context.Bookings
|
=> _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");
|
.FirstOrDefaultAsync(b => b.OtpCode == otpCode && b.Status == "Confirmed");
|
||||||
|
|
||||||
public Task<List<Booking>> GetBookingsForSession(Guid facilitySessionId)
|
public Task<List<Booking>> GetBookingsForSession(Guid facilitySessionId)
|
||||||
=> _context.Bookings
|
=> _context.Bookings
|
||||||
|
.Include(b => b.Facility)
|
||||||
|
.Include(b => b.RfidTag)
|
||||||
.Where(b => b.FKFacilitySessionId == facilitySessionId)
|
.Where(b => b.FKFacilitySessionId == facilitySessionId)
|
||||||
.OrderBy(b => b.BookedAt)
|
.OrderBy(b => b.BookedAt)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
public Task<List<Booking>> GetBookingsForCustomer(Guid customerId)
|
public Task<List<Booking>> GetBookingsForCustomer(Guid customerId)
|
||||||
=> _context.Bookings
|
=> _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)
|
.Where(b => b.FKCustomerId == customerId)
|
||||||
.OrderByDescending(b => b.BookedAt)
|
.OrderByDescending(b => b.BookedAt)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
@@ -50,5 +58,43 @@ namespace Fitness_core_BE.Repos
|
|||||||
b.FKFacilitySessionId == facilitySessionId &&
|
b.FKFacilitySessionId == facilitySessionId &&
|
||||||
b.FKCustomerId == customerId &&
|
b.FKCustomerId == customerId &&
|
||||||
b.Status != "Cancelled");
|
b.Status != "Cancelled");
|
||||||
|
|
||||||
|
public async Task<List<Booking>> 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<int> 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<List<Booking>> 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<bool> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+119
-1
@@ -41,9 +41,11 @@ namespace Fitness_core_BE.Repos
|
|||||||
?? throw new KeyNotFoundException("Facility not found");
|
?? throw new KeyNotFoundException("Facility not found");
|
||||||
|
|
||||||
existing.Name = facility.Name;
|
existing.Name = facility.Name;
|
||||||
existing.FacilityType = facility.FacilityType;
|
existing.BookingMode = facility.BookingMode;
|
||||||
existing.Description = facility.Description;
|
existing.Description = facility.Description;
|
||||||
existing.DefaultCapacity = facility.DefaultCapacity;
|
existing.DefaultCapacity = facility.DefaultCapacity;
|
||||||
|
existing.NumberOfUnits = facility.NumberOfUnits;
|
||||||
|
existing.PricePerHour = facility.PricePerHour;
|
||||||
existing.IsActive = facility.IsActive;
|
existing.IsActive = facility.IsActive;
|
||||||
|
|
||||||
return existing;
|
return existing;
|
||||||
@@ -98,5 +100,121 @@ namespace Fitness_core_BE.Repos
|
|||||||
session.IsCancelled = true;
|
session.IsCancelled = true;
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Device binding (absorbed from PlaceRepo) ---
|
||||||
|
|
||||||
|
public Task<AccessDevice?> GetDeviceByIdForClientAsync(Guid deviceId, Guid clientId)
|
||||||
|
=> _context.AccessDevices
|
||||||
|
.FirstOrDefaultAsync(d => d.DeviceId == deviceId && d.FKClientId == clientId);
|
||||||
|
|
||||||
|
public async Task<Dictionary<Guid, string>> GetFacilityNamesByIdsAsync(List<Guid> facilityIds)
|
||||||
|
{
|
||||||
|
return await _context.Facilities
|
||||||
|
.Where(f => facilityIds.Contains(f.FacilityId))
|
||||||
|
.ToDictionaryAsync(f => f.FacilityId, f => f.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Dictionary<Guid, AccessDevice>> GetDevicesByFacilityIdsAsync(List<Guid> 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<bool> 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<Guid> 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<List<Guid>> GetFacilityIdsForPlanAsync(Guid customerPlanId)
|
||||||
|
=> _context.CustomerPlanFacilities
|
||||||
|
.Where(pf => pf.FKCustomerPlanId == customerPlanId)
|
||||||
|
.Select(pf => pf.FKFacilityId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<Dictionary<Guid, List<Guid>>> GetFacilityIdsByPlanIdsAsync(List<Guid> 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<int> 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<List<Place>> 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<Place?> GetPlaceById(Guid placeId)
|
|
||||||
=> _context.Places.FirstOrDefaultAsync(p => p.PlaceId == placeId);
|
|
||||||
|
|
||||||
public async Task<Place> CreatePlace(Place place)
|
|
||||||
{
|
|
||||||
place.PlaceId = Guid.NewGuid();
|
|
||||||
place.CreatedAt = DateTime.UtcNow;
|
|
||||||
place.IsActive = true;
|
|
||||||
|
|
||||||
_context.Places.Add(place);
|
|
||||||
return place;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Place> 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<Place?> 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<AccessDevice?> GetDeviceByIdForClientAsync(Guid deviceId, Guid clientId)
|
|
||||||
=> _context.AccessDevices
|
|
||||||
.FirstOrDefaultAsync(d => d.DeviceId == deviceId && d.FKClientId == clientId);
|
|
||||||
|
|
||||||
public async Task<Dictionary<Guid, string>> GetPlaceNamesByIdsAsync(List<Guid> placeIds)
|
|
||||||
{
|
|
||||||
return await _context.Places
|
|
||||||
.Where(p => placeIds.Contains(p.PlaceId))
|
|
||||||
.ToDictionaryAsync(p => p.PlaceId, p => p.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Dictionary<Guid, AccessDevice>> GetDevicesByPlaceIdsAsync(List<Guid> 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<bool> 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<Guid> 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<List<Guid>> GetPlaceIdsForPlanAsync(Guid customerPlanId)
|
|
||||||
=> _context.CustomerPlanPlaces
|
|
||||||
.Where(pp => pp.FKCustomerPlanId == customerPlanId)
|
|
||||||
.Select(pp => pp.FKPlaceId)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
public async Task<Dictionary<Guid, List<Guid>>> GetPlaceIdsByPlanIdsAsync(List<Guid> 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<List<RfidTag>> 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<RfidTag?> GetTagById(Guid tagId, Guid clientId)
|
||||||
|
=> _context.RfidTags.FirstOrDefaultAsync(t => t.RfidTagId == tagId && t.FKClientId == clientId);
|
||||||
|
|
||||||
|
public Task<bool> 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<RfidTag> 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<RfidTag> 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<RfidTag?> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+409
-33
@@ -5,30 +5,28 @@ using Fitness_core_BE.Interfaces;
|
|||||||
using Fitness_core_BE.Interfaces.Services;
|
using Fitness_core_BE.Interfaces.Services;
|
||||||
using Fitness_core_BE.Models;
|
using Fitness_core_BE.Models;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace Fitness_core_BE.Services
|
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 IBookingRepository _bookingRepository = bookingRepository;
|
||||||
private readonly IFacilityRepository _facilityRepository;
|
private readonly IFacilityRepository _facilityRepository = facilityRepository;
|
||||||
private readonly ICustomerSubscriptionRepository _customerSubscriptionRepository;
|
private readonly IRfidTagRepository _rfidTagRepository = rfidTagRepository;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly ICustomerSubscriptionRepository _customerSubscriptionRepository = customerSubscriptionRepository;
|
||||||
private readonly UserRoleHelper _userRoleHelper;
|
private readonly IUnitOfWork _uow = uow;
|
||||||
|
private readonly UserRoleHelper _userRoleHelper = userRoleHelper;
|
||||||
|
|
||||||
|
// ---------- Scheduled-mode creation (existing behavior, now also stamping the
|
||||||
|
// denormalized FKFacilityId/StartTime/EndTime every Booking carries) ----------
|
||||||
|
|
||||||
public BookingService(
|
|
||||||
IBookingRepository bookingRepository,
|
|
||||||
IFacilityRepository facilityRepository,
|
|
||||||
ICustomerSubscriptionRepository customerSubscriptionRepository,
|
|
||||||
IUnitOfWork uow,
|
|
||||||
UserRoleHelper userRoleHelper)
|
|
||||||
{
|
|
||||||
_bookingRepository = bookingRepository;
|
|
||||||
_facilityRepository = facilityRepository;
|
|
||||||
_customerSubscriptionRepository = customerSubscriptionRepository;
|
|
||||||
_uow = uow;
|
|
||||||
_userRoleHelper = userRoleHelper;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<object> CreateMemberBooking(object payload, HttpContext httpContext)
|
public async Task<object> CreateMemberBooking(object payload, HttpContext httpContext)
|
||||||
{
|
{
|
||||||
@@ -78,6 +76,10 @@ namespace Fitness_core_BE.Services
|
|||||||
var booking = new Booking
|
var booking = new Booking
|
||||||
{
|
{
|
||||||
FKFacilitySessionId = facilitySessionId,
|
FKFacilitySessionId = facilitySessionId,
|
||||||
|
FKFacilityId = session.FKFacilityId,
|
||||||
|
Facility = session.Facility,
|
||||||
|
StartTime = session.StartTime,
|
||||||
|
EndTime = session.EndTime,
|
||||||
FKClientId = session.FKClientId,
|
FKClientId = session.FKClientId,
|
||||||
IsGuest = false,
|
IsGuest = false,
|
||||||
FKCustomerId = cusId,
|
FKCustomerId = cusId,
|
||||||
@@ -93,7 +95,7 @@ namespace Fitness_core_BE.Services
|
|||||||
|
|
||||||
await _uow.CommitAsync();
|
await _uow.CommitAsync();
|
||||||
|
|
||||||
return MapToDto(booking, session, includeOtp: false, BuildShareUrl(httpContext, booking.BookingId));
|
return MapToDto(booking, includeOtp: false, BuildShareUrl(httpContext, booking.BookingId));
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -127,6 +129,10 @@ namespace Fitness_core_BE.Services
|
|||||||
var booking = new Booking
|
var booking = new Booking
|
||||||
{
|
{
|
||||||
FKFacilitySessionId = facilitySessionId,
|
FKFacilitySessionId = facilitySessionId,
|
||||||
|
FKFacilityId = session.FKFacilityId,
|
||||||
|
Facility = session.Facility,
|
||||||
|
StartTime = session.StartTime,
|
||||||
|
EndTime = session.EndTime,
|
||||||
FKClientId = session.FKClientId,
|
FKClientId = session.FKClientId,
|
||||||
IsGuest = true,
|
IsGuest = true,
|
||||||
GuestName = guestName,
|
GuestName = guestName,
|
||||||
@@ -142,7 +148,7 @@ namespace Fitness_core_BE.Services
|
|||||||
await _uow.CommitAsync();
|
await _uow.CommitAsync();
|
||||||
|
|
||||||
// The only time the OTP is ever returned -- this response IS the guest's receipt.
|
// 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
|
catch
|
||||||
{
|
{
|
||||||
@@ -151,6 +157,123 @@ namespace Fitness_core_BE.Services
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- AdHoc-mode creation (new) ----------
|
||||||
|
|
||||||
|
public async Task<object> CreateMemberAdhocBooking(object payload, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
await _uow.BeginAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (facility, startTime, endTime, partySize) = await ValidateAdhocRequestAsync(payload);
|
||||||
|
|
||||||
|
var cusId = await _userRoleHelper.GetCusId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
|
|
||||||
|
var booking = new Booking
|
||||||
|
{
|
||||||
|
FKFacilitySessionId = null,
|
||||||
|
FKFacilityId = facility.FacilityId,
|
||||||
|
Facility = facility,
|
||||||
|
StartTime = startTime,
|
||||||
|
EndTime = endTime,
|
||||||
|
PartySize = partySize,
|
||||||
|
FKClientId = facility.FKClientId,
|
||||||
|
IsGuest = false,
|
||||||
|
FKCustomerId = cusId,
|
||||||
|
// Simplifying assumption mirroring the existing Scheduled member-booking
|
||||||
|
// path: membership covers facility use, no separate per-booking charge.
|
||||||
|
PaymentStatus = "NotRequired",
|
||||||
|
Status = "Confirmed"
|
||||||
|
};
|
||||||
|
|
||||||
|
await _bookingRepository.CreateBooking(booking);
|
||||||
|
await _uow.CommitAsync();
|
||||||
|
|
||||||
|
return MapToDto(booking, includeOtp: false, BuildShareUrl(httpContext, booking.BookingId));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await _uow.RollbackAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<object> CreateGuestAdhocBooking(object payload, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
await _uow.BeginAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (facility, startTime, endTime, partySize) = await ValidateAdhocRequestAsync(payload);
|
||||||
|
|
||||||
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
|
var guestName = data["guestName"].GetString() ?? throw new ArgumentException("guestName is required");
|
||||||
|
var guestPhone = data["guestPhone"].GetString() ?? throw new ArgumentException("guestPhone is required");
|
||||||
|
|
||||||
|
var durationHours = (decimal)(endTime - startTime).TotalHours;
|
||||||
|
|
||||||
|
var booking = new Booking
|
||||||
|
{
|
||||||
|
FKFacilitySessionId = null,
|
||||||
|
FKFacilityId = facility.FacilityId,
|
||||||
|
Facility = facility,
|
||||||
|
StartTime = startTime,
|
||||||
|
EndTime = endTime,
|
||||||
|
PartySize = partySize,
|
||||||
|
FKClientId = facility.FKClientId,
|
||||||
|
IsGuest = true,
|
||||||
|
GuestName = guestName,
|
||||||
|
GuestPhone = guestPhone,
|
||||||
|
OtpCode = GenerateOtp(),
|
||||||
|
AmountCharged = Math.Round(facility.PricePerHour * durationHours, 2),
|
||||||
|
PaymentStatus = "Unpaid",
|
||||||
|
Status = "Confirmed"
|
||||||
|
};
|
||||||
|
|
||||||
|
await _bookingRepository.CreateBooking(booking);
|
||||||
|
await _uow.CommitAsync();
|
||||||
|
|
||||||
|
return MapToDto(booking, includeOtp: true, BuildShareUrl(httpContext, booking.BookingId));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await _uow.RollbackAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Shared validation for both AdHoc creation paths: facility must be AdHoc-mode,
|
||||||
|
/// requested window must have a free unit. Locks the facility row for the duration of the
|
||||||
|
/// caller's transaction so two concurrent claims on the same facility serialize instead of
|
||||||
|
/// both reading "room available" before either commits.</summary>
|
||||||
|
private async Task<(Facility facility, DateTime startTime, DateTime endTime, int? partySize)> ValidateAdhocRequestAsync(object payload)
|
||||||
|
{
|
||||||
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
|
var facilityId = Guid.Parse(data["facilityId"].GetString()!);
|
||||||
|
var startTime = DateTime.Parse(data["startTime"].GetString()!).ToUniversalTime();
|
||||||
|
var durationMinutes = data["durationMinutes"].GetInt32();
|
||||||
|
if (durationMinutes < 1) throw new ArgumentException("durationMinutes must be at least 1");
|
||||||
|
var endTime = startTime.AddMinutes(durationMinutes);
|
||||||
|
|
||||||
|
int? partySize = data.ContainsKey("partySize") && data["partySize"].ValueKind != JsonValueKind.Null
|
||||||
|
? data["partySize"].GetInt32()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var facility = await _facilityRepository.GetFacilityById(facilityId)
|
||||||
|
?? throw new KeyNotFoundException("Facility not found");
|
||||||
|
if (facility.BookingMode != "AdHoc")
|
||||||
|
throw new ArgumentException("This facility does not support ad-hoc booking");
|
||||||
|
if (!facility.IsActive)
|
||||||
|
throw new ArgumentException("This facility is not active");
|
||||||
|
|
||||||
|
await _facilityRepository.LockFacilityForUpdateAsync(facilityId);
|
||||||
|
var overlapping = await _facilityRepository.GetOverlappingAdhocBookingCountAsync(facilityId, startTime, endTime);
|
||||||
|
if (overlapping >= facility.NumberOfUnits)
|
||||||
|
throw new ArgumentException("No units are free for the requested time");
|
||||||
|
|
||||||
|
return (facility, startTime, endTime, partySize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Payment / check-in / lifecycle ----------
|
||||||
|
|
||||||
public async Task<object> MarkGuestBookingPaid(object payload, HttpContext httpContext)
|
public async Task<object> MarkGuestBookingPaid(object payload, HttpContext httpContext)
|
||||||
{
|
{
|
||||||
await _uow.BeginAsync();
|
await _uow.BeginAsync();
|
||||||
@@ -169,7 +292,7 @@ namespace Fitness_core_BE.Services
|
|||||||
booking.AmountCharged = amountCharged;
|
booking.AmountCharged = amountCharged;
|
||||||
|
|
||||||
await _uow.CommitAsync();
|
await _uow.CommitAsync();
|
||||||
return MapToDto(booking, booking.FacilitySession, includeOtp: false);
|
return MapToDto(booking, includeOtp: false);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -185,19 +308,49 @@ namespace Fitness_core_BE.Services
|
|||||||
{
|
{
|
||||||
var data = DeserializePayload.Deserialize(payload);
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
var otpCode = data["otpCode"].GetString() ?? throw new ArgumentException("otpCode is required");
|
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)
|
var booking = await _bookingRepository.GetActiveBookingByOtp(otpCode)
|
||||||
?? throw new KeyNotFoundException("No confirmed booking found for this code");
|
?? 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)
|
if (booking.OtpVerifiedAt.HasValue)
|
||||||
throw new ArgumentException("This booking has already been checked in");
|
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.OtpVerifiedAt = DateTime.UtcNow;
|
||||||
booking.CheckedInAt = DateTime.UtcNow;
|
booking.CheckedInAt = DateTime.UtcNow;
|
||||||
booking.Status = "CheckedIn";
|
booking.Status = "CheckedIn";
|
||||||
|
|
||||||
await _uow.CommitAsync();
|
await _uow.CommitAsync();
|
||||||
return MapToDto(booking, booking.FacilitySession, includeOtp: false);
|
return MapToDto(booking, includeOtp: false);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -206,6 +359,31 @@ namespace Fitness_core_BE.Services
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Lazily reconciles everything a background job would otherwise have swept:
|
||||||
|
/// Confirmed bookings whose window passed with no check-in become NoShow; CheckedIn
|
||||||
|
/// bookings whose window passed become Completed and release their tag. Runs at the top
|
||||||
|
/// of every check-in, across every facility for this client (not scoped to whichever
|
||||||
|
/// facility is being checked into right now), so the whole roster stays accurate as long
|
||||||
|
/// as check-ins keep happening anywhere in the club.</summary>
|
||||||
|
private async Task SweepOverdueBookingsAsync(Guid clientId, DateTime now)
|
||||||
|
{
|
||||||
|
await _bookingRepository.MarkOverdueConfirmedAsNoShowAsync(clientId, now);
|
||||||
|
|
||||||
|
var overdueCheckedIn = await _bookingRepository.GetOverdueCheckedInBookingsAsync(clientId, now);
|
||||||
|
foreach (var booking in overdueCheckedIn)
|
||||||
|
{
|
||||||
|
booking.Status = "Completed";
|
||||||
|
booking.CheckedOutAt = now;
|
||||||
|
if (booking.RfidTag != null && booking.RfidReturnedAt == null)
|
||||||
|
{
|
||||||
|
booking.RfidTag.Status = "Available";
|
||||||
|
booking.RfidTag.UpdatedAt = now;
|
||||||
|
booking.RfidReturnedAt = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Caller's _uow.CommitAsync() persists these tracked changes alongside its own.
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<object> CancelBooking(object payload, HttpContext httpContext)
|
public async Task<object> CancelBooking(object payload, HttpContext httpContext)
|
||||||
{
|
{
|
||||||
await _uow.BeginAsync();
|
await _uow.BeginAsync();
|
||||||
@@ -216,8 +394,8 @@ namespace Fitness_core_BE.Services
|
|||||||
|
|
||||||
var booking = await _bookingRepository.GetBookingById(bookingId)
|
var booking = await _bookingRepository.GetBookingById(bookingId)
|
||||||
?? throw new KeyNotFoundException("Booking not found");
|
?? throw new KeyNotFoundException("Booking not found");
|
||||||
if (booking.Status == "Cancelled")
|
if (booking.Status is "Cancelled" or "NoShow" or "Completed")
|
||||||
throw new ArgumentException("This booking is already cancelled");
|
throw new ArgumentException($"This booking is already {booking.Status} and can't be cancelled");
|
||||||
if (booking.Status == "CheckedIn")
|
if (booking.Status == "CheckedIn")
|
||||||
throw new ArgumentException("This booking has already been checked in and can't be cancelled");
|
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();
|
await _uow.CommitAsync();
|
||||||
return MapToDto(booking, booking.FacilitySession, includeOtp: false);
|
return MapToDto(booking, includeOtp: false);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -244,11 +422,180 @@ namespace Fitness_core_BE.Services
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<object> ReassignBooking(object payload, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
await _uow.BeginAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
|
var bookingId = Guid.Parse(data["bookingId"].GetString()!);
|
||||||
|
|
||||||
|
var booking = await _bookingRepository.GetBookingById(bookingId)
|
||||||
|
?? throw new KeyNotFoundException("Booking not found");
|
||||||
|
if (!booking.IsGuest)
|
||||||
|
throw new ArgumentException("Only guest bookings can be reassigned");
|
||||||
|
if (booking.Status != "Confirmed")
|
||||||
|
throw new ArgumentException("Only a booking that hasn't been checked in yet can be reassigned");
|
||||||
|
|
||||||
|
if (data.ContainsKey("guestName")) booking.GuestName = data["guestName"].GetString() ?? booking.GuestName;
|
||||||
|
if (data.ContainsKey("guestPhone")) booking.GuestPhone = data["guestPhone"].GetString() ?? booking.GuestPhone;
|
||||||
|
|
||||||
|
await _uow.CommitAsync();
|
||||||
|
return MapToDto(booking, includeOtp: false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await _uow.RollbackAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<object> RescheduleBooking(object payload, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
await _uow.BeginAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
|
var bookingId = Guid.Parse(data["bookingId"].GetString()!);
|
||||||
|
|
||||||
|
var booking = await _bookingRepository.GetBookingById(bookingId)
|
||||||
|
?? throw new KeyNotFoundException("Booking not found");
|
||||||
|
if (booking.Status != "Confirmed")
|
||||||
|
throw new ArgumentException("Only a booking that hasn't been checked in yet can be rescheduled");
|
||||||
|
|
||||||
|
if (booking.FKFacilitySessionId == null)
|
||||||
|
{
|
||||||
|
// AdHoc: move to a new time on the same facility.
|
||||||
|
if (!data.ContainsKey("startTime") || !data.ContainsKey("durationMinutes"))
|
||||||
|
throw new ArgumentException("startTime and durationMinutes are required to reschedule an ad-hoc booking");
|
||||||
|
|
||||||
|
var startTime = DateTime.Parse(data["startTime"].GetString()!).ToUniversalTime();
|
||||||
|
var durationMinutes = data["durationMinutes"].GetInt32();
|
||||||
|
if (durationMinutes < 1) throw new ArgumentException("durationMinutes must be at least 1");
|
||||||
|
var endTime = startTime.AddMinutes(durationMinutes);
|
||||||
|
|
||||||
|
await _facilityRepository.LockFacilityForUpdateAsync(booking.FKFacilityId);
|
||||||
|
var overlapping = await _facilityRepository.GetOverlappingAdhocBookingCountAsync(
|
||||||
|
booking.FKFacilityId, startTime, endTime, excludeBookingId: booking.BookingId);
|
||||||
|
|
||||||
|
var facility = booking.Facility ?? await _facilityRepository.GetFacilityById(booking.FKFacilityId)
|
||||||
|
?? throw new KeyNotFoundException("Facility not found");
|
||||||
|
if (overlapping >= facility.NumberOfUnits)
|
||||||
|
throw new ArgumentException("No units are free for the requested time");
|
||||||
|
|
||||||
|
if (booking.PaymentStatus != "Paid" && booking.IsGuest)
|
||||||
|
{
|
||||||
|
var durationHours = (decimal)(endTime - startTime).TotalHours;
|
||||||
|
booking.AmountCharged = Math.Round(facility.PricePerHour * durationHours, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
booking.StartTime = startTime;
|
||||||
|
booking.EndTime = endTime;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Scheduled: move to a different pre-created session on the same facility.
|
||||||
|
if (!data.ContainsKey("facilitySessionId"))
|
||||||
|
throw new ArgumentException("facilitySessionId is required to reschedule a scheduled booking");
|
||||||
|
|
||||||
|
var newSessionId = Guid.Parse(data["facilitySessionId"].GetString()!);
|
||||||
|
var newSession = await _facilityRepository.GetFacilitySessionById(newSessionId)
|
||||||
|
?? throw new KeyNotFoundException("Facility session not found");
|
||||||
|
if (newSession.FKFacilityId != booking.FKFacilityId)
|
||||||
|
throw new ArgumentException("The new session must be on the same facility");
|
||||||
|
if (newSession.IsCancelled)
|
||||||
|
throw new ArgumentException("This session has been cancelled");
|
||||||
|
if (newSession.StartTime <= DateTime.UtcNow)
|
||||||
|
throw new ArgumentException("This session has already started");
|
||||||
|
|
||||||
|
var capacity = newSession.Capacity ?? newSession.Facility?.DefaultCapacity ?? 1;
|
||||||
|
var bookedCount = await _facilityRepository.GetActiveBookingCount(newSessionId);
|
||||||
|
// Excluding this booking's own current session membership naturally falls out
|
||||||
|
// here since bookedCount only counts rows already on newSessionId, which this
|
||||||
|
// booking isn't yet.
|
||||||
|
if (bookedCount >= capacity)
|
||||||
|
throw new ArgumentException("This session is fully booked");
|
||||||
|
|
||||||
|
booking.FKFacilitySessionId = newSessionId;
|
||||||
|
booking.StartTime = newSession.StartTime;
|
||||||
|
booking.EndTime = newSession.EndTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _uow.CommitAsync();
|
||||||
|
return MapToDto(booking, includeOtp: false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await _uow.RollbackAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<object> MarkNoShow(object payload, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
await _uow.BeginAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
|
var bookingId = Guid.Parse(data["bookingId"].GetString()!);
|
||||||
|
|
||||||
|
var booking = await _bookingRepository.GetBookingById(bookingId)
|
||||||
|
?? throw new KeyNotFoundException("Booking not found");
|
||||||
|
if (booking.Status != "Confirmed")
|
||||||
|
throw new ArgumentException("Only a Confirmed booking can be marked no-show");
|
||||||
|
|
||||||
|
booking.Status = "NoShow";
|
||||||
|
|
||||||
|
await _uow.CommitAsync();
|
||||||
|
return MapToDto(booking, includeOtp: false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await _uow.RollbackAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<object> CompleteBooking(object payload, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
await _uow.BeginAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
|
var bookingId = Guid.Parse(data["bookingId"].GetString()!);
|
||||||
|
|
||||||
|
var booking = await _bookingRepository.GetBookingById(bookingId)
|
||||||
|
?? throw new KeyNotFoundException("Booking not found");
|
||||||
|
if (booking.Status != "CheckedIn")
|
||||||
|
throw new ArgumentException("Only a CheckedIn booking can be completed");
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
booking.Status = "Completed";
|
||||||
|
booking.CheckedOutAt = now;
|
||||||
|
if (booking.RfidTag != null && booking.RfidReturnedAt == null)
|
||||||
|
{
|
||||||
|
booking.RfidTag.Status = "Available";
|
||||||
|
booking.RfidTag.UpdatedAt = now;
|
||||||
|
booking.RfidReturnedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _uow.CommitAsync();
|
||||||
|
return MapToDto(booking, includeOtp: false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await _uow.RollbackAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Reads ----------
|
||||||
|
|
||||||
public async Task<object> GetMyBookings(object payload, HttpContext httpContext)
|
public async Task<object> GetMyBookings(object payload, HttpContext httpContext)
|
||||||
{
|
{
|
||||||
var cusId = await _userRoleHelper.GetCusId(DataExtractor.GetUserIdFromContext(httpContext));
|
var cusId = await _userRoleHelper.GetCusId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
var bookings = await _bookingRepository.GetBookingsForCustomer(cusId);
|
var bookings = await _bookingRepository.GetBookingsForCustomer(cusId);
|
||||||
return bookings.Select(b => MapToDto(b, b.FacilitySession, includeOtp: false)).ToList();
|
return bookings.Select(b => MapToDto(b, includeOtp: false)).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<object> GetSessionBookings(object payload, HttpContext httpContext)
|
public async Task<object> GetSessionBookings(object payload, HttpContext httpContext)
|
||||||
@@ -259,7 +606,28 @@ namespace Fitness_core_BE.Services
|
|||||||
var bookings = await _bookingRepository.GetBookingsForSession(facilitySessionId);
|
var bookings = await _bookingRepository.GetBookingsForSession(facilitySessionId);
|
||||||
// Staff view -- never expose guest OTPs here either; the door check-in flow reads
|
// 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.
|
// the OTP the guest already has, it doesn't need staff to look it up beforehand.
|
||||||
return bookings.Select(b => MapToDto(b, b.FacilitySession, includeOtp: false)).ToList();
|
return bookings.Select(b => MapToDto(b, includeOtp: false)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<object> GetBookings(object payload, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
|
|
||||||
|
Guid? facilityId = data.ContainsKey("facilityId") && data["facilityId"].ValueKind != JsonValueKind.Null
|
||||||
|
? Guid.Parse(data["facilityId"].GetString()!) : null;
|
||||||
|
Guid? facilitySessionId = data.ContainsKey("facilitySessionId") && data["facilitySessionId"].ValueKind != JsonValueKind.Null
|
||||||
|
? Guid.Parse(data["facilitySessionId"].GetString()!) : null;
|
||||||
|
DateTime? fromUtc = data.ContainsKey("fromDate") && data["fromDate"].ValueKind != JsonValueKind.Null
|
||||||
|
? DateTime.Parse(data["fromDate"].GetString()!).ToUniversalTime() : null;
|
||||||
|
DateTime? toUtc = data.ContainsKey("toDate") && data["toDate"].ValueKind != JsonValueKind.Null
|
||||||
|
? DateTime.Parse(data["toDate"].GetString()!).ToUniversalTime() : null;
|
||||||
|
string? status = data.ContainsKey("status") && data["status"].ValueKind != JsonValueKind.Null
|
||||||
|
? data["status"].GetString() : null;
|
||||||
|
|
||||||
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
|
var bookings = await _bookingRepository.GetBookings(clientId, facilityId, facilitySessionId, fromUtc, toUtc, status);
|
||||||
|
|
||||||
|
return bookings.Select(b => MapToDto(b, includeOtp: false)).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<object?> GetBookingView(object payload)
|
public async Task<object?> GetBookingView(object payload)
|
||||||
@@ -273,7 +641,7 @@ namespace Fitness_core_BE.Services
|
|||||||
|
|
||||||
// The share link itself is the credential (an unguessable GUID) -- the OTP is only
|
// 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.
|
// 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();
|
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)
|
private static string BuildShareUrl(HttpContext httpContext, Guid bookingId)
|
||||||
=> $"{httpContext.Request.Scheme}://{httpContext.Request.Host}/api/public/bookings/{bookingId}/view";
|
=> $"{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,
|
BookingId = booking.BookingId,
|
||||||
FacilitySessionId = booking.FKFacilitySessionId,
|
FacilitySessionId = booking.FKFacilitySessionId,
|
||||||
FacilityName = session?.Facility?.Name,
|
FacilityId = booking.FKFacilityId,
|
||||||
SessionStartTime = session?.StartTime,
|
FacilityName = booking.Facility?.Name,
|
||||||
SessionEndTime = session?.EndTime,
|
BookingMode = booking.Facility?.BookingMode ?? (booking.FKFacilitySessionId.HasValue ? "Scheduled" : "AdHoc"),
|
||||||
|
StartTime = booking.StartTime,
|
||||||
|
EndTime = booking.EndTime,
|
||||||
|
PartySize = booking.PartySize,
|
||||||
IsGuest = booking.IsGuest,
|
IsGuest = booking.IsGuest,
|
||||||
CustomerId = booking.FKCustomerId,
|
CustomerId = booking.FKCustomerId,
|
||||||
GuestName = booking.GuestName,
|
GuestName = booking.GuestName,
|
||||||
GuestPhone = booking.GuestPhone,
|
GuestPhone = booking.GuestPhone,
|
||||||
OtpCode = includeOtp ? booking.OtpCode : null,
|
OtpCode = includeOtp ? booking.OtpCode : null,
|
||||||
OtpVerified = booking.OtpVerifiedAt.HasValue,
|
OtpVerified = booking.OtpVerifiedAt.HasValue,
|
||||||
|
RfidTagId = booking.FKRfidTagId,
|
||||||
|
RfidTagLabel = booking.RfidTag?.Label ?? booking.RfidTag?.TagCode,
|
||||||
|
RfidAssignedAt = booking.RfidAssignedAt,
|
||||||
|
RfidReturnedAt = booking.RfidReturnedAt,
|
||||||
PaymentStatus = booking.PaymentStatus,
|
PaymentStatus = booking.PaymentStatus,
|
||||||
AmountCharged = booking.AmountCharged,
|
AmountCharged = booking.AmountCharged,
|
||||||
Status = booking.Status,
|
Status = booking.Status,
|
||||||
BookedAt = booking.BookedAt,
|
BookedAt = booking.BookedAt,
|
||||||
CheckedInAt = booking.CheckedInAt,
|
CheckedInAt = booking.CheckedInAt,
|
||||||
|
CheckedOutAt = booking.CheckedOutAt,
|
||||||
ShareUrl = shareUrl
|
ShareUrl = shareUrl
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ using Fitness_core_BE.Helpers;
|
|||||||
|
|
||||||
namespace Fitness_core_BE.Services.CustomerPlanService
|
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 ScopeIndividual = "Individual";
|
||||||
private const string ScopeGroup = "Group";
|
private const string ScopeGroup = "Group";
|
||||||
@@ -20,22 +24,10 @@ namespace Fitness_core_BE.Services.CustomerPlanService
|
|||||||
private const string BillingPrepaid = "Prepaid";
|
private const string BillingPrepaid = "Prepaid";
|
||||||
private const string BillingPostpaid = "Postpaid";
|
private const string BillingPostpaid = "Postpaid";
|
||||||
|
|
||||||
private readonly ICustomerPlanRepository _customerPlanRepository;
|
private readonly ICustomerPlanRepository _customerPlanRepository = customerPlanRepository;
|
||||||
private readonly IPlaceRepo _placeRepo;
|
private readonly IFacilityRepository _facilityRepository = facilityRepository;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow = uow;
|
||||||
private readonly UserRoleHelper _userRoleHelper;
|
private readonly UserRoleHelper _userRoleHelper = userRoleHelper;
|
||||||
|
|
||||||
public CustomerPlanService(
|
|
||||||
ICustomerPlanRepository customerPlanRepository,
|
|
||||||
IPlaceRepo placeRepo,
|
|
||||||
IUnitOfWork uow,
|
|
||||||
UserRoleHelper userRoleHelper)
|
|
||||||
{
|
|
||||||
_customerPlanRepository = customerPlanRepository;
|
|
||||||
_placeRepo = placeRepo;
|
|
||||||
_uow = uow;
|
|
||||||
_userRoleHelper = userRoleHelper;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<object> GetAllCustomerPlans(object payload, HttpContext httpContext)
|
public async Task<object> GetAllCustomerPlans(object payload, HttpContext httpContext)
|
||||||
{
|
{
|
||||||
@@ -58,9 +50,9 @@ namespace Fitness_core_BE.Services.CustomerPlanService
|
|||||||
|
|
||||||
var plans = await _customerPlanRepository.GetAllCustomerPlans(includeInactive, clientId);
|
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 allPlaceIds = placeIdsByPlan.Values.SelectMany(ids => ids).Distinct().ToList();
|
||||||
var placeNames = await _placeRepo.GetPlaceNamesByIdsAsync(allPlaceIds);
|
var placeNames = await _facilityRepository.GetFacilityNamesByIdsAsync(allPlaceIds);
|
||||||
|
|
||||||
return plans.Select(p =>
|
return plans.Select(p =>
|
||||||
{
|
{
|
||||||
@@ -142,7 +134,7 @@ namespace Fitness_core_BE.Services.CustomerPlanService
|
|||||||
|
|
||||||
var placeIds = DataExtractor.GetOptionalGuidList(data, "placeIds");
|
var placeIds = DataExtractor.GetOptionalGuidList(data, "placeIds");
|
||||||
if (placeIds != null)
|
if (placeIds != null)
|
||||||
await _placeRepo.SetPlanPlacesAsync(result.CustomerPlanId, placeIds);
|
await _facilityRepository.SetPlanFacilitiesAsync(result.CustomerPlanId, placeIds);
|
||||||
|
|
||||||
await _uow.CommitAsync();
|
await _uow.CommitAsync();
|
||||||
|
|
||||||
@@ -206,7 +198,7 @@ namespace Fitness_core_BE.Services.CustomerPlanService
|
|||||||
|
|
||||||
var placeIds = DataExtractor.GetOptionalGuidList(data, "placeIds");
|
var placeIds = DataExtractor.GetOptionalGuidList(data, "placeIds");
|
||||||
if (placeIds != null)
|
if (placeIds != null)
|
||||||
await _placeRepo.SetPlanPlacesAsync(result.CustomerPlanId, placeIds);
|
await _facilityRepository.SetPlanFacilitiesAsync(result.CustomerPlanId, placeIds);
|
||||||
|
|
||||||
await _uow.CommitAsync();
|
await _uow.CommitAsync();
|
||||||
|
|
||||||
@@ -400,8 +392,8 @@ namespace Fitness_core_BE.Services.CustomerPlanService
|
|||||||
|
|
||||||
private async Task<CustomerPlanDto> MapToDtoWithPlacesAsync(Models.CustomerPlan plan, string? clientName)
|
private async Task<CustomerPlanDto> MapToDtoWithPlacesAsync(Models.CustomerPlan plan, string? clientName)
|
||||||
{
|
{
|
||||||
var placeIds = await _placeRepo.GetPlaceIdsForPlanAsync(plan.CustomerPlanId);
|
var placeIds = await _facilityRepository.GetFacilityIdsForPlanAsync(plan.CustomerPlanId);
|
||||||
var placeNames = await _placeRepo.GetPlaceNamesByIdsAsync(placeIds);
|
var placeNames = await _facilityRepository.GetFacilityNamesByIdsAsync(placeIds);
|
||||||
return MapToDto(plan, clientName, placeIds, placeNames);
|
return MapToDto(plan, clientName, placeIds, placeNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+162
-22
@@ -10,7 +10,7 @@ namespace Fitness_core_BE.Services
|
|||||||
{
|
{
|
||||||
public class FacilityService : IFacilityService
|
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 IFacilityRepository _facilityRepository;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
@@ -31,7 +31,9 @@ namespace Fitness_core_BE.Services
|
|||||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
var facilities = await _facilityRepository.GetAllFacilities(clientId, includeInactive);
|
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<object> CreateFacility(object payload, HttpContext httpContext)
|
public async Task<object> CreateFacility(object payload, HttpContext httpContext)
|
||||||
@@ -41,11 +43,22 @@ namespace Fitness_core_BE.Services
|
|||||||
{
|
{
|
||||||
var data = DeserializePayload.Deserialize(payload);
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
if (!data.ContainsKey("name")) throw new KeyNotFoundException("name is required");
|
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() ?? "";
|
var bookingMode = data.ContainsKey("bookingMode") ? data["bookingMode"].GetString() ?? "Scheduled" : "Scheduled";
|
||||||
if (!ValidFacilityTypes.Contains(facilityType))
|
if (!ValidBookingModes.Contains(bookingMode))
|
||||||
throw new ArgumentException($"facilityType must be one of: {string.Join(", ", ValidFacilityTypes)}");
|
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));
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
|
|
||||||
@@ -53,18 +66,17 @@ namespace Fitness_core_BE.Services
|
|||||||
{
|
{
|
||||||
FKClientId = clientId,
|
FKClientId = clientId,
|
||||||
Name = data["name"].GetString() ?? "",
|
Name = data["name"].GetString() ?? "",
|
||||||
FacilityType = facilityType,
|
BookingMode = bookingMode,
|
||||||
Description = data.ContainsKey("description") ? data["description"].GetString() : null,
|
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);
|
var result = await _facilityRepository.CreateFacility(facility);
|
||||||
await _uow.CommitAsync();
|
await _uow.CommitAsync();
|
||||||
|
|
||||||
return MapToDto(result);
|
return MapToDto(result, null);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -86,12 +98,12 @@ namespace Fitness_core_BE.Services
|
|||||||
?? throw new KeyNotFoundException("Facility not found");
|
?? throw new KeyNotFoundException("Facility not found");
|
||||||
|
|
||||||
if (data.ContainsKey("name")) existing.Name = data["name"].GetString() ?? existing.Name;
|
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;
|
var bookingMode = data["bookingMode"].GetString() ?? existing.BookingMode;
|
||||||
if (!ValidFacilityTypes.Contains(facilityType))
|
if (!ValidBookingModes.Contains(bookingMode))
|
||||||
throw new ArgumentException($"facilityType must be one of: {string.Join(", ", ValidFacilityTypes)}");
|
throw new ArgumentException($"bookingMode must be one of: {string.Join(", ", ValidBookingModes)}");
|
||||||
existing.FacilityType = facilityType;
|
existing.BookingMode = bookingMode;
|
||||||
}
|
}
|
||||||
if (data.ContainsKey("description")) existing.Description = data["description"].GetString();
|
if (data.ContainsKey("description")) existing.Description = data["description"].GetString();
|
||||||
if (data.ContainsKey("defaultCapacity"))
|
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");
|
if (capacity < 1) throw new ArgumentException("defaultCapacity must be at least 1");
|
||||||
existing.DefaultCapacity = capacity;
|
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();
|
if (data.ContainsKey("isActive")) existing.IsActive = data["isActive"].GetBoolean();
|
||||||
|
|
||||||
var result = await _facilityRepository.UpdateFacility(existing);
|
var result = await _facilityRepository.UpdateFacility(existing);
|
||||||
await _uow.CommitAsync();
|
await _uow.CommitAsync();
|
||||||
|
|
||||||
return MapToDto(result);
|
var devicesByFacility = await _facilityRepository.GetDevicesByFacilityIdsAsync(new List<Guid> { result.FacilityId });
|
||||||
|
return MapToDto(result, devicesByFacility.GetValueOrDefault(result.FacilityId));
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -128,7 +153,7 @@ namespace Fitness_core_BE.Services
|
|||||||
?? throw new KeyNotFoundException("Facility not found");
|
?? throw new KeyNotFoundException("Facility not found");
|
||||||
|
|
||||||
await _uow.CommitAsync();
|
await _uow.CommitAsync();
|
||||||
return MapToDto(facility);
|
return MapToDto(facility, null);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -154,6 +179,9 @@ namespace Fitness_core_BE.Services
|
|||||||
if (facility == null || facility.FKClientId != clientId)
|
if (facility == null || facility.FKClientId != clientId)
|
||||||
throw new KeyNotFoundException("Facility not found");
|
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 startTime = DateTime.Parse(data["startTime"].GetString()!).ToUniversalTime();
|
||||||
var endTime = DateTime.Parse(data["endTime"].GetString()!).ToUniversalTime();
|
var endTime = DateTime.Parse(data["endTime"].GetString()!).ToUniversalTime();
|
||||||
if (endTime <= startTime)
|
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<object> AssignDevice(object payload, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
await _uow.BeginAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
|
if (!data.ContainsKey("facilityId")) throw new KeyNotFoundException("facilityId is required");
|
||||||
|
|
||||||
|
var facilityId = Guid.Parse(data["facilityId"].GetString()!);
|
||||||
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
|
|
||||||
|
var facility = await _facilityRepository.GetFacilityById(facilityId);
|
||||||
|
if (facility == null || facility.FKClientId != clientId)
|
||||||
|
throw new KeyNotFoundException("Facility not found");
|
||||||
|
|
||||||
|
var hasDeviceId = data.ContainsKey("deviceId") && data["deviceId"].ValueKind != JsonValueKind.Null;
|
||||||
|
if (!hasDeviceId)
|
||||||
|
{
|
||||||
|
await _facilityRepository.UnassignDeviceAsync(facilityId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var deviceId = Guid.Parse(data["deviceId"].GetString()!);
|
||||||
|
var device = await _facilityRepository.GetDeviceByIdForClientAsync(deviceId, clientId)
|
||||||
|
?? throw new KeyNotFoundException("Device not found");
|
||||||
|
|
||||||
|
if (await _facilityRepository.IsDeviceAssignedToAnotherFacilityAsync(deviceId, facilityId))
|
||||||
|
throw new ArgumentException("This device is already assigned to another facility.");
|
||||||
|
|
||||||
|
await _facilityRepository.AssignDeviceAsync(facilityId, deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _uow.CommitAsync();
|
||||||
|
|
||||||
|
var devicesByFacility = await _facilityRepository.GetDevicesByFacilityIdsAsync(new List<Guid> { facilityId });
|
||||||
|
return MapToDto(facility, devicesByFacility.GetValueOrDefault(facilityId));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await _uow.RollbackAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Availability (new) ---
|
||||||
|
|
||||||
|
public async Task<object> GetAvailability(object payload, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
|
if (!data.ContainsKey("startTime")) throw new KeyNotFoundException("startTime is required");
|
||||||
|
if (!data.ContainsKey("durationMinutes")) throw new KeyNotFoundException("durationMinutes is required");
|
||||||
|
|
||||||
|
var startTime = DateTime.Parse(data["startTime"].GetString()!).ToUniversalTime();
|
||||||
|
var durationMinutes = data["durationMinutes"].GetInt32();
|
||||||
|
if (durationMinutes < 1) throw new ArgumentException("durationMinutes must be at least 1");
|
||||||
|
var endTime = startTime.AddMinutes(durationMinutes);
|
||||||
|
|
||||||
|
Guid? facilityId = data.ContainsKey("facilityId") && data["facilityId"].ValueKind != JsonValueKind.Null
|
||||||
|
? Guid.Parse(data["facilityId"].GetString()!)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
|
var facilities = facilityId.HasValue
|
||||||
|
? new List<Facility> { await _facilityRepository.GetFacilityById(facilityId.Value) is { } f && f.FKClientId == clientId ? f : throw new KeyNotFoundException("Facility not found") }
|
||||||
|
: await _facilityRepository.GetAllFacilities(clientId, includeInactive: false);
|
||||||
|
|
||||||
|
var result = new List<FacilityAvailabilityDto>();
|
||||||
|
foreach (var facility in facilities)
|
||||||
|
{
|
||||||
|
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,
|
FacilityId = facility.FacilityId,
|
||||||
Name = facility.Name,
|
Name = facility.Name,
|
||||||
FacilityType = facility.FacilityType,
|
BookingMode = facility.BookingMode,
|
||||||
Description = facility.Description,
|
Description = facility.Description,
|
||||||
DefaultCapacity = facility.DefaultCapacity,
|
DefaultCapacity = facility.DefaultCapacity,
|
||||||
|
NumberOfUnits = facility.NumberOfUnits,
|
||||||
|
PricePerHour = facility.PricePerHour,
|
||||||
IsActive = facility.IsActive,
|
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()
|
private static FacilitySessionDto MapToDto(FacilitySession session, string? facilityName = null, int bookedCount = 0) => new()
|
||||||
|
|||||||
@@ -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<object> 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<object> 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<object> 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<Guid> { result.PlaceId });
|
|
||||||
return MapToDto(result, devicesByPlace.GetValueOrDefault(result.PlaceId));
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
await _uow.RollbackAsync();
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<object> 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<Guid> { place.PlaceId });
|
|
||||||
return MapToDto(place, devicesByPlace.GetValueOrDefault(place.PlaceId));
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
await _uow.RollbackAsync();
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<object> 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<Guid> { 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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<object> 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<object> 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<object> 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<object> 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
-18
@@ -64,16 +64,19 @@ namespace Fitness_core_BE
|
|||||||
public DbSet<CustomerWorkoutExerciseCompletion> CustomerWorkoutExerciseCompletions => Set<CustomerWorkoutExerciseCompletion>();
|
public DbSet<CustomerWorkoutExerciseCompletion> CustomerWorkoutExerciseCompletions => Set<CustomerWorkoutExerciseCompletion>();
|
||||||
|
|
||||||
// Multi-facility session booking + RFID time-bank cards (pool/court/class booking, one
|
// 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<Facility> Facilities => Set<Facility>();
|
public DbSet<Facility> Facilities => Set<Facility>();
|
||||||
public DbSet<FacilitySession> FacilitySessions => Set<FacilitySession>();
|
public DbSet<FacilitySession> FacilitySessions => Set<FacilitySession>();
|
||||||
public DbSet<Booking> Bookings => Set<Booking>();
|
public DbSet<Booking> Bookings => Set<Booking>();
|
||||||
public DbSet<TimeBankCard> TimeBankCards => Set<TimeBankCard>();
|
public DbSet<TimeBankCard> TimeBankCards => Set<TimeBankCard>();
|
||||||
public DbSet<TimeBankCardUsageLog> TimeBankCardUsageLogs => Set<TimeBankCardUsageLog>();
|
public DbSet<TimeBankCardUsageLog> TimeBankCardUsageLogs => Set<TimeBankCardUsageLog>();
|
||||||
|
public DbSet<CustomerPlanFacility> CustomerPlanFacilities => Set<CustomerPlanFacility>();
|
||||||
|
|
||||||
// Physical access zones (Pool Area, Indoor Gym, ...) -- see Models/Place.cs.
|
// Booking-scoped RFID tag inventory (assigned to a Booking at check-in, released at
|
||||||
public DbSet<Place> Places => Set<Place>();
|
// Complete/no-show sweep) -- see Models/RfidTag.cs. Not to be confused with
|
||||||
public DbSet<CustomerPlanPlace> CustomerPlanPlaces => Set<CustomerPlanPlace>();
|
// TimeBankCard's own, unrelated RfidTagId string field.
|
||||||
|
public DbSet<RfidTag> RfidTags => Set<RfidTag>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -183,12 +186,14 @@ namespace Fitness_core_BE
|
|||||||
modelBuilder.Entity<Booking>().Ignore(b => b.Client);
|
modelBuilder.Entity<Booking>().Ignore(b => b.Client);
|
||||||
modelBuilder.Entity<TimeBankCard>().Ignore(t => t.Client);
|
modelBuilder.Entity<TimeBankCard>().Ignore(t => t.Client);
|
||||||
modelBuilder.Entity<TimeBankCardUsageLog>().Ignore(l => l.Client);
|
modelBuilder.Entity<TimeBankCardUsageLog>().Ignore(l => l.Client);
|
||||||
modelBuilder.Entity<Place>().Ignore(p => p.Client);
|
|
||||||
|
|
||||||
// A session's capacity is enforced by counting non-cancelled bookings against it at
|
// 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
|
// 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
|
// 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<Booking>()
|
modelBuilder.Entity<Booking>()
|
||||||
.HasIndex(b => new { b.FKFacilitySessionId, b.FKCustomerId })
|
.HasIndex(b => new { b.FKFacilitySessionId, b.FKCustomerId })
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
@@ -241,22 +246,37 @@ namespace Fitness_core_BE
|
|||||||
// same-context FK anymore (same pattern as every other central-pointing scalar).
|
// same-context FK anymore (same pattern as every other central-pointing scalar).
|
||||||
modelBuilder.Entity<AccessDevice>().Ignore(d => d.Agent);
|
modelBuilder.Entity<AccessDevice>().Ignore(d => d.Agent);
|
||||||
|
|
||||||
// One device per place for now -- see AccessDevice.FKPlaceId comment. Lifting
|
// One device per facility for now -- see AccessDevice.FKFacilityId comment. Lifting
|
||||||
// this to many-devices-per-place later is just dropping this index.
|
// this to many-devices-per-facility later is just dropping this index.
|
||||||
modelBuilder.Entity<AccessDevice>()
|
modelBuilder.Entity<AccessDevice>()
|
||||||
.HasIndex(d => d.FKPlaceId)
|
.HasIndex(d => d.FKFacilityId)
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasFilter("`FKPlaceId` IS NOT NULL");
|
.HasFilter("`FKFacilityId` IS NOT NULL");
|
||||||
|
|
||||||
modelBuilder.Entity<CustomerPlanPlace>()
|
modelBuilder.Entity<CustomerPlanFacility>()
|
||||||
.HasIndex(pp => new { pp.FKCustomerPlanId, pp.FKPlaceId })
|
.HasIndex(pf => new { pf.FKCustomerPlanId, pf.FKFacilityId })
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
modelBuilder.Entity<CustomerPlanPlace>()
|
modelBuilder.Entity<CustomerPlanFacility>()
|
||||||
.HasOne(pp => pp.CustomerPlan).WithMany()
|
.HasOne(pf => pf.CustomerPlan).WithMany()
|
||||||
.HasForeignKey(pp => pp.FKCustomerPlanId).OnDelete(DeleteBehavior.Cascade);
|
.HasForeignKey(pf => pf.FKCustomerPlanId).OnDelete(DeleteBehavior.Cascade);
|
||||||
modelBuilder.Entity<CustomerPlanPlace>()
|
modelBuilder.Entity<CustomerPlanFacility>()
|
||||||
.HasOne(pp => pp.Place).WithMany()
|
.HasOne(pf => pf.Facility).WithMany()
|
||||||
.HasForeignKey(pp => pp.FKPlaceId).OnDelete(DeleteBehavior.Restrict);
|
.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<RfidTag>()
|
||||||
|
.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<Booking>()
|
||||||
|
.HasIndex(b => new { b.FKFacilityId, b.StartTime, b.EndTime });
|
||||||
|
|
||||||
modelBuilder.Entity<UnlinkedDeviceEnrollment>()
|
modelBuilder.Entity<UnlinkedDeviceEnrollment>()
|
||||||
.HasIndex(e => new { e.FKDeviceId, e.DeviceEmployeeNo, e.LinkedAt });
|
.HasIndex(e => new { e.FKDeviceId, e.DeviceEmployeeNo, e.LinkedAt });
|
||||||
|
|||||||
Reference in New Issue
Block a user