backend phase 8
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
|
||||
namespace Baya.Application.Contracts.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// The pre-payment <c>booking_requests</c> aggregate. Writes load tracked, tenancy-scoped rows (the caller's
|
||||
/// customer/nurse id) so an illegal cross-party access is a clean not-found, never a leak; reads project to
|
||||
/// role-scoped DTOs. The expiry sweep selects stale rows through the covering indexes in bounded batches.
|
||||
/// No money and no <c>bookings</c> row exist anywhere here.
|
||||
/// </summary>
|
||||
public interface IBookingRequestRepository
|
||||
{
|
||||
Task AddAsync(BookingRequest request, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Tracked, customer-owned lookup for cancel. NULL if not owned/absent (existence not leaked).</summary>
|
||||
Task<BookingRequest?> GetTrackedForCustomerAsync(long id, long customerId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Tracked, nurse-assigned lookup for accept/reject, with the <c>Customer</c> navigation loaded
|
||||
/// so the handler can notify the customer's user id. NULL if not the caller's/absent.</summary>
|
||||
Task<BookingRequest?> GetTrackedForNurseAsync(long id, long nurseId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Tracked <c>pending_nurse_response</c> rows whose response deadline has passed (with
|
||||
/// <c>Customer</c> loaded), capped at <paramref name="batchSize"/> — one bounded page of the expiry sweep.</summary>
|
||||
Task<IReadOnlyList<BookingRequest>> GetStalePendingAsync(DateTime now, int batchSize, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Tracked <c>accepted_awaiting_payment</c> rows whose payment window has lapsed (with
|
||||
/// <c>Customer</c> loaded), capped at <paramref name="batchSize"/>.</summary>
|
||||
Task<IReadOnlyList<BookingRequest>> GetStaleAcceptedAsync(DateTime now, int batchSize, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The customer's own inbox — counterparty is the nurse (name + rating), actionable first.</summary>
|
||||
Task<PagedResult<BookingRequestListItemDto>> ListForCustomerAsync(long customerId, string? status, int page, int pageSize, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The nurse's inbox — counterparty is the patient; exposes stage-1 <c>customer_notes</c> only.</summary>
|
||||
Task<PagedResult<BookingRequestListItemDto>> ListForNurseAsync(long nurseId, string? status, int page, int pageSize, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Role-agnostic detail projection (carries both party ids for authorization + the full address
|
||||
/// for the customer/admin path). NULL when absent.</summary>
|
||||
Task<BookingRequestDetailProjection?> GetDetailAsync(long id, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -26,4 +26,9 @@ public interface INurseProfileRepository
|
||||
/// <summary>The nurse's <c>nurse_profiles.id</c> + decrypted national id — what the bank-account
|
||||
/// ownership inquiry needs. NULL when the user has no nurse profile yet.</summary>
|
||||
Task<NurseIdentityContext?> GetIdentityContextByUserIdAsync(int userId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The target nurse's booking-relevant facts (user id, gender, verified/accepting gates) in one
|
||||
/// read — what a booking-request create needs to notify the nurse and run the bookability + same-gender
|
||||
/// checks. NULL when no such nurse profile exists.</summary>
|
||||
Task<NurseBookingContext?> GetBookingContextByIdAsync(long nurseProfileId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ public interface IUnitOfWork
|
||||
public ICatalogRepository CatalogRepository { get; }
|
||||
public INurseServiceVariantRepository NurseServiceVariantRepository { get; }
|
||||
public IVerificationRepository VerificationRepository { get; }
|
||||
public IBookingRequestRepository BookingRequestRepository { get; }
|
||||
Task CommitAsync();
|
||||
ValueTask RollBackAsync();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Booking;
|
||||
|
||||
namespace Baya.Application.Features.Booking;
|
||||
|
||||
/// <summary>
|
||||
/// Maps the role-agnostic detail projection to the wire DTO. <paramref name="includeFullAddress"/> is the
|
||||
/// stage-1 disclosure switch: the owning customer (and admin) get the full decrypted address; the nurse
|
||||
/// gets only the coarse city/district location plus <c>customer_notes</c>, never the encrypted address line.
|
||||
/// </summary>
|
||||
internal static class BookingRequestMapper
|
||||
{
|
||||
public static BookingRequestDto ToDto(BookingRequestDetailProjection p, bool includeFullAddress)
|
||||
=> new(
|
||||
p.Id,
|
||||
p.Status,
|
||||
p.NurseId,
|
||||
p.NurseName,
|
||||
p.NurseRating,
|
||||
p.NurseTotalReviews,
|
||||
p.PatientId,
|
||||
p.PatientName,
|
||||
p.VariantId,
|
||||
p.VariantLabel,
|
||||
p.VariantPriceUnit,
|
||||
p.CustomerAddressId,
|
||||
p.AddressTitle,
|
||||
p.CityId,
|
||||
p.CityNameFa,
|
||||
p.CityNameEn,
|
||||
p.DistrictId,
|
||||
p.DistrictNameFa,
|
||||
p.DistrictNameEn,
|
||||
includeFullAddress ? p.AddressLine : null,
|
||||
includeFullAddress ? p.PostalCode : null,
|
||||
includeFullAddress ? p.RecipientName : null,
|
||||
includeFullAddress ? p.RecipientPhone : null,
|
||||
p.RequiredCaregiverGender,
|
||||
p.RequestedDate,
|
||||
p.RequestedTimeStart,
|
||||
p.RequestedTimeEnd,
|
||||
p.CustomerNotes,
|
||||
p.NurseResponseDeadlineAt,
|
||||
p.PaymentDeadlineAt,
|
||||
p.NurseRejectionReason,
|
||||
p.CreatedAt);
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
#nullable enable
|
||||
using System.Text.Json;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Commands.AcceptBookingRequest;
|
||||
|
||||
internal sealed class AcceptBookingRequestCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IPlatformConfig platformConfig,
|
||||
IDateTimeProvider dateTimeProvider,
|
||||
INotificationDispatcher notifications)
|
||||
: IRequestHandler<AcceptBookingRequestCommand, OperationResult<BookingRequestDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<BookingRequestDto>> Handle(AcceptBookingRequestCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<BookingRequestDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
|
||||
return OperationResult<BookingRequestDto>.ForbiddenResult("Only a nurse can accept a booking request.");
|
||||
|
||||
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (nurseId is not { } nid)
|
||||
return OperationResult<BookingRequestDto>.ForbiddenResult("No nurse profile exists yet.");
|
||||
|
||||
var bookingRequest = await unitOfWork.BookingRequestRepository.GetTrackedForNurseAsync(request.Id, nid, cancellationToken);
|
||||
if (bookingRequest is null)
|
||||
return OperationResult<BookingRequestDto>.NotFoundResult("Booking request not found.");
|
||||
|
||||
if (!bookingRequest.CanTransitionTo(BookingRequestStatus.AcceptedAwaitingPayment))
|
||||
return OperationResult<BookingRequestDto>.ConflictResult("This request can no longer be accepted.");
|
||||
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
|
||||
// Self-guard against an already-passed response deadline — the expiry sweep may not have run yet, so
|
||||
// the command must not trust that it has.
|
||||
if (bookingRequest.NurseResponseDeadlineAt <= now)
|
||||
return OperationResult<BookingRequestDto>.ConflictResult("The response deadline has passed; this request can no longer be accepted.");
|
||||
|
||||
// Frozen from config, never the literal 30.
|
||||
var paymentDeadlineMinutes = await platformConfig.GetConfig<int>("booking_payment_deadline_minutes", cancellationToken);
|
||||
bookingRequest.Accept(now.AddMinutes(paymentDeadlineMinutes));
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
await notifications.DispatchAsync(
|
||||
new Notification(
|
||||
bookingRequest.Customer.UserId,
|
||||
"booking_request_accepted",
|
||||
"Booking request accepted",
|
||||
"The nurse accepted your request. Pay within the payment window to confirm your booking.",
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
booking_request_id = bookingRequest.Id,
|
||||
payment_deadline_at = bookingRequest.PaymentDeadlineAt?.ToString("O")
|
||||
})),
|
||||
cancellationToken);
|
||||
|
||||
var detail = await unitOfWork.BookingRequestRepository.GetDetailAsync(bookingRequest.Id, cancellationToken);
|
||||
// Nurse view — stage-1 disclosure masks the encrypted full address.
|
||||
return OperationResult<BookingRequestDto>.SuccessResult(BookingRequestMapper.ToDto(detail!, includeFullAddress: false));
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Commands.AcceptBookingRequest;
|
||||
|
||||
/// <summary>The assigned nurse accepts a pending request, opening the config-driven 30-minute payment
|
||||
/// window. <c>Id</c> comes from the route. No booking and no money are created — accept only opens the
|
||||
/// window.</summary>
|
||||
public record AcceptBookingRequestCommand(long Id = 0) : IRequest<OperationResult<BookingRequestDto>>;
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Commands.CancelBookingRequest;
|
||||
|
||||
internal sealed class CancelBookingRequestCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<CancelBookingRequestCommand, OperationResult<BookingRequestDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<BookingRequestDto>> Handle(CancelBookingRequestCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<BookingRequestDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
|
||||
return OperationResult<BookingRequestDto>.ForbiddenResult("Only a customer can cancel a booking request.");
|
||||
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (customerId is not { } cid)
|
||||
return OperationResult<BookingRequestDto>.ForbiddenResult("No customer profile exists yet.");
|
||||
|
||||
var bookingRequest = await unitOfWork.BookingRequestRepository.GetTrackedForCustomerAsync(request.Id, cid, cancellationToken);
|
||||
if (bookingRequest is null)
|
||||
return OperationResult<BookingRequestDto>.NotFoundResult("Booking request not found.");
|
||||
|
||||
if (!bookingRequest.CanTransitionTo(BookingRequestStatus.CancelledByCustomer))
|
||||
return OperationResult<BookingRequestDto>.ConflictResult("This request can no longer be cancelled.");
|
||||
|
||||
bookingRequest.CancelByCustomer();
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
var detail = await unitOfWork.BookingRequestRepository.GetDetailAsync(bookingRequest.Id, cancellationToken);
|
||||
// Owning customer — full address.
|
||||
return OperationResult<BookingRequestDto>.SuccessResult(BookingRequestMapper.ToDto(detail!, includeFullAddress: true));
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Commands.CancelBookingRequest;
|
||||
|
||||
/// <summary>The customer withdraws a request that is still pending or accepted-awaiting-payment (before they
|
||||
/// pay). <c>Id</c> comes from the route. This is a request cancellation only — a booking cancellation with
|
||||
/// refund tiers is b9+/DEFERRED.</summary>
|
||||
public record CancelBookingRequestCommand(long Id = 0) : IRequest<OperationResult<BookingRequestDto>>;
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
#nullable enable
|
||||
using System.Text.Json;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Commands.CreateBookingRequest;
|
||||
|
||||
internal sealed class CreateBookingRequestCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IPlatformConfig platformConfig,
|
||||
IDateTimeProvider dateTimeProvider,
|
||||
INotificationDispatcher notifications)
|
||||
: IRequestHandler<CreateBookingRequestCommand, OperationResult<BookingRequestDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<BookingRequestDto>> Handle(CreateBookingRequestCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<BookingRequestDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
|
||||
return OperationResult<BookingRequestDto>.ForbiddenResult("Only a customer can create a booking request.");
|
||||
|
||||
// Tenancy anchor: the customer id is resolved from the caller, never trusted from the body.
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (customerId is not { } cid)
|
||||
return OperationResult<BookingRequestDto>.FailureResult("No customer profile exists yet. Create your profile first.");
|
||||
|
||||
// Tenancy invariant: patient + address must belong to the caller; the variant must belong to the
|
||||
// requested nurse. A mismatch is a clean not-found — never a 500, never a leak of another party's row.
|
||||
var patient = await unitOfWork.PatientRepository.GetOwnedAsync(request.PatientId, cid, cancellationToken);
|
||||
if (patient is null)
|
||||
return OperationResult<BookingRequestDto>.NotFoundResult("Patient not found.");
|
||||
|
||||
var address = await unitOfWork.CustomerAddressRepository.GetOwnedAsync(request.CustomerAddressId, cid, cancellationToken);
|
||||
if (address is null)
|
||||
return OperationResult<BookingRequestDto>.NotFoundResult("Address not found.");
|
||||
|
||||
var variant = await unitOfWork.NurseServiceVariantRepository.GetOwnedAsync(request.VariantId, request.NurseId, cancellationToken);
|
||||
if (variant is null)
|
||||
return OperationResult<BookingRequestDto>.NotFoundResult("Service variant not found for this nurse.");
|
||||
|
||||
if (!variant.IsActive)
|
||||
return OperationResult<BookingRequestDto>.FailureResult(nameof(request.VariantId), "This service variant is not currently offered.");
|
||||
|
||||
var nurse = await unitOfWork.NurseProfileRepository.GetBookingContextByIdAsync(request.NurseId, cancellationToken);
|
||||
if (nurse is null)
|
||||
return OperationResult<BookingRequestDto>.NotFoundResult("Nurse not found.");
|
||||
|
||||
if (!nurse.IsVerified || !nurse.IsAcceptingBookings)
|
||||
return OperationResult<BookingRequestDto>.FailureResult(nameof(request.NurseId), "This nurse is not currently accepting bookings.");
|
||||
|
||||
// Same-gender care is decisive for bodily care — a male/female requirement must match the nurse's
|
||||
// gender at request time; "any" matches either. Never defaulted, never advisory.
|
||||
if (!CaregiverGender.Matches(request.RequiredCaregiverGender, nurse.Gender ?? string.Empty))
|
||||
return OperationResult<BookingRequestDto>.FailureResult(
|
||||
nameof(request.RequiredCaregiverGender),
|
||||
$"This nurse's gender does not match the required caregiver gender '{request.RequiredCaregiverGender}'.");
|
||||
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
if (request.RequestedDate < DateOnly.FromDateTime(now))
|
||||
return OperationResult<BookingRequestDto>.FailureResult(nameof(request.RequestedDate), "The requested date cannot be in the past.");
|
||||
|
||||
// Deadline is read from config and FROZEN as an absolute timestamp so a later config change can never
|
||||
// move an existing request's deadline.
|
||||
var responseDeadlineHours = await platformConfig.GetConfig<int>("nurse_response_deadline_hours", cancellationToken);
|
||||
|
||||
var bookingRequest = new BookingRequest
|
||||
{
|
||||
CustomerId = cid,
|
||||
NurseId = request.NurseId,
|
||||
PatientId = request.PatientId,
|
||||
VariantId = request.VariantId,
|
||||
CustomerAddressId = request.CustomerAddressId,
|
||||
RequiredCaregiverGender = request.RequiredCaregiverGender,
|
||||
RequestedDate = request.RequestedDate,
|
||||
RequestedTimeStart = request.RequestedTimeStart,
|
||||
RequestedTimeEnd = request.RequestedTimeEnd,
|
||||
CustomerNotes = string.IsNullOrWhiteSpace(request.CustomerNotes) ? null : request.CustomerNotes.Trim(),
|
||||
NurseResponseDeadlineAt = now.AddHours(responseDeadlineHours)
|
||||
};
|
||||
|
||||
await unitOfWork.BookingRequestRepository.AddAsync(bookingRequest, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
// The dispatcher self-commits its own row — invoke it only AFTER the request is persisted. Stage-1
|
||||
// disclosure: the payload carries the patient display name + date, nothing more.
|
||||
await notifications.DispatchAsync(
|
||||
new Notification(
|
||||
nurse.UserId,
|
||||
"booking_request_received",
|
||||
"New booking request",
|
||||
$"A new booking request from a customer awaits your response.",
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
booking_request_id = bookingRequest.Id,
|
||||
patient_display_name = patient.DisplayName,
|
||||
requested_date = request.RequestedDate.ToString("O")
|
||||
})),
|
||||
cancellationToken);
|
||||
|
||||
var detail = await unitOfWork.BookingRequestRepository.GetDetailAsync(bookingRequest.Id, cancellationToken);
|
||||
// The creator is the owning customer, so they see their own full address.
|
||||
return OperationResult<BookingRequestDto>.SuccessResult(BookingRequestMapper.ToDto(detail!, includeFullAddress: true));
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Commands.CreateBookingRequest;
|
||||
|
||||
public sealed class CreateBookingRequestCommandValidator : AbstractValidator<CreateBookingRequestCommand>
|
||||
{
|
||||
public CreateBookingRequestCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.NurseId).GreaterThan(0);
|
||||
RuleFor(x => x.VariantId).GreaterThan(0);
|
||||
RuleFor(x => x.PatientId).GreaterThan(0);
|
||||
RuleFor(x => x.CustomerAddressId).GreaterThan(0);
|
||||
|
||||
RuleFor(x => x.RequestedTimeEnd)
|
||||
.GreaterThan(x => x.RequestedTimeStart)
|
||||
.WithMessage("requested_time_end must be after requested_time_start.");
|
||||
|
||||
// Gender is load-bearing — it must be supplied and in the closed set; never silently defaulted.
|
||||
RuleFor(x => x.RequiredCaregiverGender)
|
||||
.NotEmpty()
|
||||
.Must(CaregiverGender.IsValid)
|
||||
.WithMessage("required_caregiver_gender must be one of: male, female, any.");
|
||||
|
||||
RuleFor(x => x.CustomerNotes).MaximumLength(1000);
|
||||
|
||||
// requested_date "not in the past" needs the injected clock, so it is enforced in the handler.
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Commands.CreateBookingRequest;
|
||||
|
||||
/// <summary>
|
||||
/// A customer requests a specific nurse for a patient, service variant, address, date and time, with a
|
||||
/// required caregiver gender and optional stage-1 <c>customer_notes</c>. The customer is derived from the
|
||||
/// caller, never the body. The handler enforces the tenancy invariant (patient + address ∈ caller,
|
||||
/// variant ∈ nurse), the same-gender match, bookability, and freezes the response deadline from config.
|
||||
/// No money and no booking are created — only a <c>pending_nurse_response</c> request.
|
||||
/// </summary>
|
||||
public record CreateBookingRequestCommand(
|
||||
long NurseId,
|
||||
long VariantId,
|
||||
long PatientId,
|
||||
long CustomerAddressId,
|
||||
DateOnly RequestedDate,
|
||||
TimeOnly RequestedTimeStart,
|
||||
TimeOnly RequestedTimeEnd,
|
||||
string RequiredCaregiverGender,
|
||||
string? CustomerNotes) : IRequest<OperationResult<BookingRequestDto>>;
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
#nullable enable
|
||||
using System.Text.Json;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Commands.ExpireBookingRequests;
|
||||
|
||||
internal sealed class ExpireBookingRequestsCommandHandler(
|
||||
IUnitOfWork unitOfWork,
|
||||
IDateTimeProvider dateTimeProvider,
|
||||
INotificationDispatcher notifications)
|
||||
: IRequestHandler<ExpireBookingRequestsCommand, OperationResult<ExpireBookingRequestsResult>>
|
||||
{
|
||||
private const int BatchSize = 100;
|
||||
|
||||
public async ValueTask<OperationResult<ExpireBookingRequestsResult>> Handle(ExpireBookingRequestsCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var expiredNoResponse = await SweepAsync(
|
||||
(now, ct) => unitOfWork.BookingRequestRepository.GetStalePendingAsync(now, BatchSize, ct),
|
||||
BookingRequestStatus.ExpiredNoResponse,
|
||||
r => r.ExpireNoResponse(),
|
||||
"booking_request_expired_no_response",
|
||||
"Booking request expired",
|
||||
"No nurse responded to your request in time. Please try another nurse.",
|
||||
cancellationToken);
|
||||
|
||||
var paymentExpired = await SweepAsync(
|
||||
(now, ct) => unitOfWork.BookingRequestRepository.GetStaleAcceptedAsync(now, BatchSize, ct),
|
||||
BookingRequestStatus.PaymentDeadlineExpired,
|
||||
r => r.ExpirePaymentWindow(),
|
||||
"booking_request_payment_window_expired",
|
||||
"Payment window expired",
|
||||
"The payment window for your accepted request lapsed. Please request again to book.",
|
||||
cancellationToken);
|
||||
|
||||
return OperationResult<ExpireBookingRequestsResult>.SuccessResult(
|
||||
new ExpireBookingRequestsResult(expiredNoResponse, paymentExpired));
|
||||
}
|
||||
|
||||
private async Task<int> SweepAsync(
|
||||
Func<DateTime, CancellationToken, Task<IReadOnlyList<BookingRequest>>> loadStale,
|
||||
string targetStatus,
|
||||
Action<BookingRequest> transition,
|
||||
string notificationType,
|
||||
string title,
|
||||
string body,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var total = 0;
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
var batch = await loadStale(now, cancellationToken);
|
||||
if (batch.Count == 0)
|
||||
break;
|
||||
|
||||
var moved = new List<BookingRequest>(batch.Count);
|
||||
foreach (var row in batch)
|
||||
{
|
||||
// Re-entrant guard: a row a racing accept/cancel already moved out of the expected state is
|
||||
// skipped rather than clobbered.
|
||||
if (!row.CanTransitionTo(targetStatus))
|
||||
continue;
|
||||
|
||||
transition(row);
|
||||
moved.Add(row);
|
||||
}
|
||||
|
||||
if (moved.Count == 0)
|
||||
break;
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
foreach (var row in moved)
|
||||
await notifications.DispatchAsync(
|
||||
new Notification(
|
||||
row.Customer.UserId,
|
||||
notificationType,
|
||||
title,
|
||||
body,
|
||||
JsonSerializer.Serialize(new { booking_request_id = row.Id })),
|
||||
cancellationToken);
|
||||
|
||||
total += moved.Count;
|
||||
|
||||
// A short page means the stale set is drained; the next page would be empty.
|
||||
if (batch.Count < BatchSize)
|
||||
break;
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Commands.ExpireBookingRequests;
|
||||
|
||||
/// <summary>
|
||||
/// Transitions stale requests: <c>pending_nurse_response → expired_no_response</c> once the response
|
||||
/// deadline passes, and <c>accepted_awaiting_payment → payment_deadline_expired</c> once the payment window
|
||||
/// lapses. Runs on an interval via a hosted <c>BackgroundService</c> and is also an admin manual trigger.
|
||||
/// Bounded, paginated, time-injected, and idempotent — a row a concurrent action already moved is simply
|
||||
/// not reloaded (the status predicate is the guard).
|
||||
/// </summary>
|
||||
public record ExpireBookingRequestsCommand : IRequest<OperationResult<ExpireBookingRequestsResult>>;
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
#nullable enable
|
||||
using System.Text.Json;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Commands.RejectBookingRequest;
|
||||
|
||||
internal sealed class RejectBookingRequestCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
INotificationDispatcher notifications)
|
||||
: IRequestHandler<RejectBookingRequestCommand, OperationResult<BookingRequestDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<BookingRequestDto>> Handle(RejectBookingRequestCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<BookingRequestDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
|
||||
return OperationResult<BookingRequestDto>.ForbiddenResult("Only a nurse can reject a booking request.");
|
||||
|
||||
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (nurseId is not { } nid)
|
||||
return OperationResult<BookingRequestDto>.ForbiddenResult("No nurse profile exists yet.");
|
||||
|
||||
var bookingRequest = await unitOfWork.BookingRequestRepository.GetTrackedForNurseAsync(request.Id, nid, cancellationToken);
|
||||
if (bookingRequest is null)
|
||||
return OperationResult<BookingRequestDto>.NotFoundResult("Booking request not found.");
|
||||
|
||||
if (!bookingRequest.CanTransitionTo(BookingRequestStatus.RejectedByNurse))
|
||||
return OperationResult<BookingRequestDto>.ConflictResult("This request can no longer be rejected.");
|
||||
|
||||
bookingRequest.Reject(request.Reason.Trim());
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
await notifications.DispatchAsync(
|
||||
new Notification(
|
||||
bookingRequest.Customer.UserId,
|
||||
"booking_request_rejected",
|
||||
"Booking request declined",
|
||||
"The nurse declined your request.",
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
booking_request_id = bookingRequest.Id,
|
||||
reason = bookingRequest.NurseRejectionReason
|
||||
})),
|
||||
cancellationToken);
|
||||
|
||||
var detail = await unitOfWork.BookingRequestRepository.GetDetailAsync(bookingRequest.Id, cancellationToken);
|
||||
return OperationResult<BookingRequestDto>.SuccessResult(BookingRequestMapper.ToDto(detail!, includeFullAddress: false));
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Commands.RejectBookingRequest;
|
||||
|
||||
public sealed class RejectBookingRequestCommandValidator : AbstractValidator<RejectBookingRequestCommand>
|
||||
{
|
||||
public RejectBookingRequestCommandValidator()
|
||||
{
|
||||
// Id is route-supplied — not validated here (see FluentValidation activation note in server CLAUDE.md).
|
||||
RuleFor(x => x.Reason)
|
||||
.NotEmpty()
|
||||
.MaximumLength(500);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Commands.RejectBookingRequest;
|
||||
|
||||
/// <summary>The assigned nurse declines a pending request with a required reason. <c>Id</c> comes from the
|
||||
/// route; only <c>Reason</c> is body input.</summary>
|
||||
public record RejectBookingRequestCommand(string Reason, long Id = 0) : IRequest<OperationResult<BookingRequestDto>>;
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Queries.GetBookingRequest;
|
||||
|
||||
internal sealed class GetBookingRequestQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<GetBookingRequestQuery, OperationResult<BookingRequestDto>>
|
||||
{
|
||||
private static readonly string[] AdminRoles =
|
||||
[RoleNames.Admin, RoleNames.SuperAdmin, RoleNames.Support, RoleNames.Finance, RoleNames.Moderation];
|
||||
|
||||
public async ValueTask<OperationResult<BookingRequestDto>> Handle(GetBookingRequestQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<BookingRequestDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var detail = await unitOfWork.BookingRequestRepository.GetDetailAsync(request.Id, cancellationToken);
|
||||
if (detail is null)
|
||||
return OperationResult<BookingRequestDto>.NotFoundResult("Booking request not found.");
|
||||
|
||||
var isAdmin = currentUser.Roles?.Any(AdminRoles.Contains) == true;
|
||||
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (customerId == detail.CustomerId)
|
||||
return OperationResult<BookingRequestDto>.SuccessResult(BookingRequestMapper.ToDto(detail, includeFullAddress: true));
|
||||
|
||||
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (nurseId == detail.NurseId)
|
||||
return OperationResult<BookingRequestDto>.SuccessResult(BookingRequestMapper.ToDto(detail, includeFullAddress: false));
|
||||
|
||||
if (isAdmin)
|
||||
return OperationResult<BookingRequestDto>.SuccessResult(BookingRequestMapper.ToDto(detail, includeFullAddress: true));
|
||||
|
||||
// Neither party nor admin — do not leak that the request exists.
|
||||
return OperationResult<BookingRequestDto>.NotFoundResult("Booking request not found.");
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Queries.GetBookingRequest;
|
||||
|
||||
/// <summary>A single request, visible only to its customer (full address), its nurse (stage-1 masked
|
||||
/// address), or an admin. Any other caller gets a not-found — existence is never leaked.</summary>
|
||||
public record GetBookingRequestQuery(long Id) : IRequest<OperationResult<BookingRequestDto>>;
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Queries.ListBookingRequests;
|
||||
|
||||
internal sealed class ListBookingRequestsQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<ListBookingRequestsQuery, OperationResult<PagedResult<BookingRequestListItemDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<PagedResult<BookingRequestListItemDto>>> Handle(ListBookingRequestsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<PagedResult<BookingRequestListItemDto>>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
|
||||
|
||||
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
|
||||
var asNurse = ResolveInboxRole(request.Role, nurseId, customerId);
|
||||
if (asNurse is null)
|
||||
{
|
||||
if (nurseId is null && customerId is null)
|
||||
return OperationResult<PagedResult<BookingRequestListItemDto>>.SuccessResult(
|
||||
new PagedResult<BookingRequestListItemDto>([], 0, page, pageSize));
|
||||
|
||||
return OperationResult<PagedResult<BookingRequestListItemDto>>.FailureResult(
|
||||
nameof(request.Role), "Specify role=customer or role=nurse — you hold both roles.");
|
||||
}
|
||||
|
||||
var result = asNurse.Value
|
||||
? await unitOfWork.BookingRequestRepository.ListForNurseAsync(nurseId!.Value, request.Status, page, pageSize, cancellationToken)
|
||||
: await unitOfWork.BookingRequestRepository.ListForCustomerAsync(customerId!.Value, request.Status, page, pageSize, cancellationToken);
|
||||
|
||||
return OperationResult<PagedResult<BookingRequestListItemDto>>.SuccessResult(result);
|
||||
}
|
||||
|
||||
// Returns true = nurse inbox, false = customer inbox, null = undecidable (no profile, or both without a
|
||||
// Role hint).
|
||||
private static bool? ResolveInboxRole(string? role, long? nurseId, long? customerId)
|
||||
{
|
||||
if (string.Equals(role, RoleNames.Nurse, StringComparison.OrdinalIgnoreCase))
|
||||
return nurseId is not null ? true : null;
|
||||
if (string.Equals(role, RoleNames.Customer, StringComparison.OrdinalIgnoreCase))
|
||||
return customerId is not null ? false : null;
|
||||
|
||||
return (nurseId, customerId) switch
|
||||
{
|
||||
(not null, null) => true,
|
||||
(null, not null) => false,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Queries.ListBookingRequests;
|
||||
|
||||
/// <summary>
|
||||
/// The role-scoped inbox: a customer sees their own requests, a nurse sees requests addressed to them.
|
||||
/// <c>Role</c> disambiguates a user who holds both roles (<c>customer</c>/<c>nurse</c>); when omitted it is
|
||||
/// inferred from which profile the caller has. Optional <c>Status</c> filter; actionable rows sort first.
|
||||
/// </summary>
|
||||
public record ListBookingRequestsQuery(
|
||||
string? Status = null,
|
||||
string? Role = null,
|
||||
int Page = 1,
|
||||
int PageSize = 50) : IRequest<OperationResult<PagedResult<BookingRequestListItemDto>>>;
|
||||
@@ -0,0 +1,44 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Models.Booking;
|
||||
|
||||
/// <summary>
|
||||
/// The repository's role-agnostic detail projection for a single request. It carries both parties' ids so
|
||||
/// the handler can authorize the caller and decide visibility, plus the full (decrypted) address fields so
|
||||
/// the handler can surface them to the owning customer/admin and <b>mask</b> them for the nurse. The
|
||||
/// handler maps this to the public <see cref="BookingRequestDto"/>; the encrypted fields never leave the
|
||||
/// customer/admin path.
|
||||
/// </summary>
|
||||
public record BookingRequestDetailProjection(
|
||||
long Id,
|
||||
string Status,
|
||||
long CustomerId,
|
||||
long NurseId,
|
||||
string NurseName,
|
||||
decimal NurseRating,
|
||||
int NurseTotalReviews,
|
||||
long PatientId,
|
||||
string PatientName,
|
||||
long VariantId,
|
||||
string VariantLabel,
|
||||
string VariantPriceUnit,
|
||||
long CustomerAddressId,
|
||||
string AddressTitle,
|
||||
long CityId,
|
||||
string CityNameFa,
|
||||
string CityNameEn,
|
||||
long? DistrictId,
|
||||
string? DistrictNameFa,
|
||||
string? DistrictNameEn,
|
||||
string? AddressLine,
|
||||
string? PostalCode,
|
||||
string? RecipientName,
|
||||
string? RecipientPhone,
|
||||
string? RequiredCaregiverGender,
|
||||
DateOnly RequestedDate,
|
||||
TimeOnly RequestedTimeStart,
|
||||
TimeOnly RequestedTimeEnd,
|
||||
string? CustomerNotes,
|
||||
DateTime NurseResponseDeadlineAt,
|
||||
DateTime? PaymentDeadlineAt,
|
||||
string? NurseRejectionReason,
|
||||
DateTimeOffset CreatedAt);
|
||||
@@ -0,0 +1,42 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Models.Booking;
|
||||
|
||||
/// <summary>
|
||||
/// The full single-request view returned by create/accept/reject/cancel and the detail query. The
|
||||
/// <b>nurse</b> view masks the encrypted full address (<see cref="AddressLine"/>/<see cref="PostalCode"/>/
|
||||
/// recipient) — stage-1 disclosure surfaces only <see cref="CustomerNotes"/> plus a coarse city/district
|
||||
/// location. The <b>customer</b> (and admin) view returns their own full address.
|
||||
/// </summary>
|
||||
public record BookingRequestDto(
|
||||
long Id,
|
||||
string Status,
|
||||
long NurseId,
|
||||
string NurseName,
|
||||
decimal NurseRating,
|
||||
int NurseTotalReviews,
|
||||
long PatientId,
|
||||
string PatientName,
|
||||
long VariantId,
|
||||
string VariantLabel,
|
||||
string VariantPriceUnit,
|
||||
long CustomerAddressId,
|
||||
string AddressTitle,
|
||||
long CityId,
|
||||
string CityNameFa,
|
||||
string CityNameEn,
|
||||
long? DistrictId,
|
||||
string? DistrictNameFa,
|
||||
string? DistrictNameEn,
|
||||
string? AddressLine,
|
||||
string? PostalCode,
|
||||
string? RecipientName,
|
||||
string? RecipientPhone,
|
||||
string? RequiredCaregiverGender,
|
||||
DateOnly RequestedDate,
|
||||
TimeOnly RequestedTimeStart,
|
||||
TimeOnly RequestedTimeEnd,
|
||||
string? CustomerNotes,
|
||||
DateTime NurseResponseDeadlineAt,
|
||||
DateTime? PaymentDeadlineAt,
|
||||
string? NurseRejectionReason,
|
||||
DateTimeOffset CreatedAt);
|
||||
@@ -0,0 +1,21 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Models.Booking;
|
||||
|
||||
/// <summary>
|
||||
/// One row in the role-scoped inbox. <see cref="CounterpartyName"/> is the nurse's name in the customer
|
||||
/// inbox and the patient's name in the nurse inbox; <see cref="NurseRating"/> is populated only in the
|
||||
/// customer inbox, and <see cref="CustomerNotes"/> (stage-1 disclosure) only in the nurse inbox. No
|
||||
/// encrypted/clinical field is ever projected here.
|
||||
/// </summary>
|
||||
public record BookingRequestListItemDto(
|
||||
long Id,
|
||||
string Status,
|
||||
string CounterpartyName,
|
||||
decimal? NurseRating,
|
||||
string? RequiredCaregiverGender,
|
||||
DateOnly RequestedDate,
|
||||
TimeOnly RequestedTimeStart,
|
||||
TimeOnly RequestedTimeEnd,
|
||||
DateTime NurseResponseDeadlineAt,
|
||||
DateTime? PaymentDeadlineAt,
|
||||
string? CustomerNotes);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace Baya.Application.Models.Booking;
|
||||
|
||||
/// <summary>How many stale requests each expiry sweep moved — surfaced by the admin manual trigger and used
|
||||
/// by the sweep's logging. Running the sweep again with no stale rows returns zeros (idempotent).</summary>
|
||||
public record ExpireBookingRequestsResult(int ExpiredNoResponse, int PaymentDeadlineExpired);
|
||||
@@ -0,0 +1,13 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Models.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// The facts a booking-request create needs about the target nurse in one read: their <see cref="UserId"/>
|
||||
/// (for the request-received notification), their <see cref="Gender"/> (for the same-gender match), and the
|
||||
/// two bookability gates (<see cref="IsVerified"/>, <see cref="IsAcceptingBookings"/>).
|
||||
/// </summary>
|
||||
public record NurseBookingContext(
|
||||
int UserId,
|
||||
string? Gender,
|
||||
bool IsVerified,
|
||||
bool IsAcceptingBookings);
|
||||
@@ -0,0 +1,99 @@
|
||||
#nullable enable
|
||||
using Baya.Domain.Common;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
|
||||
namespace Baya.Domain.Entities.Booking;
|
||||
|
||||
/// <summary>
|
||||
/// A customer's <b>pre-payment intent</b> for a specific nurse, patient, service variant, address, date and
|
||||
/// time. It is deliberately money-free and separate from <c>bookings</c> (b9): a request can be rejected,
|
||||
/// time out, or have its payment window lapse without a booking ever existing. The nurse sees only the
|
||||
/// limited, unencrypted <see cref="CustomerNotes"/> before accepting — stage 1 of the two-stage clinical
|
||||
/// disclosure boundary (the full encrypted care instructions are b9's, post-confirmation).
|
||||
/// <para>
|
||||
/// Both deadlines are <b>computed once from config and frozen</b> on the row, so a later config change can
|
||||
/// never move an existing request's deadlines. Status changes only through the forward-only
|
||||
/// <see cref="BookingRequestTransitions"/> guard.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class BookingRequest : BaseEntity<long>
|
||||
{
|
||||
public long CustomerId { get; set; }
|
||||
public CustomerProfile Customer { get; set; } = null!;
|
||||
|
||||
public long NurseId { get; set; }
|
||||
public NurseProfile Nurse { get; set; } = null!;
|
||||
|
||||
public long PatientId { get; set; }
|
||||
public Patient Patient { get; set; } = null!;
|
||||
|
||||
public long VariantId { get; set; }
|
||||
public NurseServiceVariant Variant { get; set; } = null!;
|
||||
|
||||
public long CustomerAddressId { get; set; }
|
||||
public CustomerAddress CustomerAddress { get; set; } = null!;
|
||||
|
||||
/// <summary>Closed code set — see <see cref="CaregiverGender"/>. Matched against the nurse's gender at
|
||||
/// request time; a first-class filter, never a soft preference.</summary>
|
||||
public string? RequiredCaregiverGender { get; set; }
|
||||
|
||||
public DateOnly RequestedDate { get; set; }
|
||||
public TimeOnly RequestedTimeStart { get; set; }
|
||||
public TimeOnly RequestedTimeEnd { get; set; }
|
||||
|
||||
/// <summary><b>Unencrypted, request-stage only</b> — the ONLY clinical context the nurse sees before
|
||||
/// accepting (Principle 6, stage 1). Never routed through the field encryptor; deliberately limited.</summary>
|
||||
public string? CustomerNotes { get; set; }
|
||||
|
||||
/// <summary>Guarded — mutated only through the transition methods so every write goes through the
|
||||
/// forward-only status machine.</summary>
|
||||
public string Status { get; private set; } = BookingRequestStatus.PendingNurseResponse;
|
||||
|
||||
/// <summary>UTC <c>datetime2</c>, frozen at create time (<c>now + nurse_response_deadline_hours</c>),
|
||||
/// immune to later config changes. After it passes an unanswered request auto-expires. Stored as
|
||||
/// <see cref="DateTime"/> (not <see cref="DateTimeOffset"/>) because it is compared/sorted in queries and
|
||||
/// the SQLite test provider cannot translate <c>DateTimeOffset</c> operators.</summary>
|
||||
public DateTime NurseResponseDeadlineAt { get; set; }
|
||||
|
||||
/// <summary>Null until accept; then frozen to <c>now + booking_payment_deadline_minutes</c> (= 30). UTC.</summary>
|
||||
public DateTime? PaymentDeadlineAt { get; private set; }
|
||||
|
||||
public string? NurseRejectionReason { get; private set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
|
||||
public bool CanTransitionTo(string target) => BookingRequestTransitions.CanTransition(Status, target);
|
||||
|
||||
/// <summary>Nurse accepts — opens the (already-computed) 30-minute payment window. No booking, no money.</summary>
|
||||
public void Accept(DateTime paymentDeadlineAt)
|
||||
{
|
||||
Transition(BookingRequestStatus.AcceptedAwaitingPayment);
|
||||
PaymentDeadlineAt = paymentDeadlineAt;
|
||||
}
|
||||
|
||||
public void Reject(string reason)
|
||||
{
|
||||
Transition(BookingRequestStatus.RejectedByNurse);
|
||||
NurseRejectionReason = reason;
|
||||
}
|
||||
|
||||
public void CancelByCustomer() => Transition(BookingRequestStatus.CancelledByCustomer);
|
||||
|
||||
public void ExpireNoResponse() => Transition(BookingRequestStatus.ExpiredNoResponse);
|
||||
|
||||
public void ExpirePaymentWindow() => Transition(BookingRequestStatus.PaymentDeadlineExpired);
|
||||
|
||||
/// <summary>Marks the request converted — called only by b9 when it creates the <c>bookings</c> row.</summary>
|
||||
public void MarkConverted() => Transition(BookingRequestStatus.Converted);
|
||||
|
||||
// Callers pre-check with CanTransitionTo and return a clean 409; reaching an illegal edge here is a
|
||||
// programming error, so it fails fast rather than silently overwriting a terminal state.
|
||||
private void Transition(string target)
|
||||
{
|
||||
if (!BookingRequestTransitions.CanTransition(Status, target))
|
||||
throw new InvalidOperationException($"Illegal booking-request transition {Status} → {target}.");
|
||||
|
||||
Status = target;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace Baya.Domain.Entities.Booking;
|
||||
|
||||
/// <summary>
|
||||
/// The closed status vocabulary of a <see cref="BookingRequest"/> — the <b>pre-payment</b> half of the
|
||||
/// engagement lifecycle. Persisted as these stable snake_case codes (never a C# enum member name) so the
|
||||
/// DB and the wire read the same. The forward-only edges live in <see cref="BookingRequestTransitions"/>.
|
||||
/// </summary>
|
||||
public static class BookingRequestStatus
|
||||
{
|
||||
/// <summary>Awaiting the nurse's accept/reject before <c>nurse_response_deadline_at</c>.</summary>
|
||||
public const string PendingNurseResponse = "pending_nurse_response";
|
||||
|
||||
/// <summary>Nurse accepted; the 30-minute <c>payment_deadline_at</c> window is open. No money yet.</summary>
|
||||
public const string AcceptedAwaitingPayment = "accepted_awaiting_payment";
|
||||
|
||||
/// <summary>A booking was created from this request (set by b9 on payment capture). Terminal.</summary>
|
||||
public const string Converted = "converted";
|
||||
|
||||
/// <summary>Nurse declined with a reason. Terminal.</summary>
|
||||
public const string RejectedByNurse = "rejected_by_nurse";
|
||||
|
||||
/// <summary>The nurse never responded before the deadline (set by the expiry sweep). Terminal.</summary>
|
||||
public const string ExpiredNoResponse = "expired_no_response";
|
||||
|
||||
/// <summary>The 30-minute payment window lapsed unpaid (set by the expiry sweep). Terminal.</summary>
|
||||
public const string PaymentDeadlineExpired = "payment_deadline_expired";
|
||||
|
||||
/// <summary>The customer withdrew the request before paying. Terminal.</summary>
|
||||
public const string CancelledByCustomer = "cancelled_by_customer";
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace Baya.Domain.Entities.Booking;
|
||||
|
||||
/// <summary>
|
||||
/// The forward-only status machine for <see cref="BookingRequest"/>. Every accept/reject/cancel/expire/
|
||||
/// convert edge is validated here, so an illegal transition is a clean conflict (never a silent overwrite),
|
||||
/// and the terminal states have no outgoing edge. b9 reuses this pattern for the <c>bookings</c> machine.
|
||||
/// </summary>
|
||||
public static class BookingRequestTransitions
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, IReadOnlyCollection<string>> Allowed =
|
||||
new Dictionary<string, IReadOnlyCollection<string>>
|
||||
{
|
||||
[BookingRequestStatus.PendingNurseResponse] =
|
||||
[
|
||||
BookingRequestStatus.AcceptedAwaitingPayment,
|
||||
BookingRequestStatus.RejectedByNurse,
|
||||
BookingRequestStatus.ExpiredNoResponse,
|
||||
BookingRequestStatus.CancelledByCustomer
|
||||
],
|
||||
[BookingRequestStatus.AcceptedAwaitingPayment] =
|
||||
[
|
||||
BookingRequestStatus.Converted,
|
||||
BookingRequestStatus.PaymentDeadlineExpired,
|
||||
BookingRequestStatus.CancelledByCustomer
|
||||
],
|
||||
// Terminal states — no outgoing edges.
|
||||
[BookingRequestStatus.Converted] = [],
|
||||
[BookingRequestStatus.RejectedByNurse] = [],
|
||||
[BookingRequestStatus.ExpiredNoResponse] = [],
|
||||
[BookingRequestStatus.PaymentDeadlineExpired] = [],
|
||||
[BookingRequestStatus.CancelledByCustomer] = []
|
||||
};
|
||||
|
||||
public static bool CanTransition(string from, string to)
|
||||
=> Allowed.TryGetValue(from, out var targets) && targets.Contains(to);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Baya.Domain.Entities.Booking;
|
||||
|
||||
/// <summary>
|
||||
/// The closed code set for <c>required_caregiver_gender</c>. Same-gender bodily care is decisive in the
|
||||
/// Iranian context, so this is a first-class filter matched against the nurse's gender at request time —
|
||||
/// never a soft preference and never silently defaulted. <see cref="Any"/> matches either nurse gender.
|
||||
/// </summary>
|
||||
public static class CaregiverGender
|
||||
{
|
||||
public const string Male = "male";
|
||||
public const string Female = "female";
|
||||
public const string Any = "any";
|
||||
|
||||
public static bool IsValid(string value)
|
||||
=> value is Male or Female or Any;
|
||||
|
||||
/// <summary>
|
||||
/// True when a nurse of <paramref name="nurseGender"/> satisfies the required caregiver gender.
|
||||
/// <see cref="Any"/> always matches; <see cref="Male"/>/<see cref="Female"/> require an exact match.
|
||||
/// </summary>
|
||||
public static bool Matches(string required, string nurseGender)
|
||||
=> required == Any || string.Equals(required, nurseGender, System.StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
Reference in New Issue
Block a user