backend phase 8

This commit is contained in:
hamid
2026-07-06 02:48:56 +03:30
parent 99ebf5d881
commit 2cfc082a04
55 changed files with 7480 additions and 7 deletions
@@ -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);
}
@@ -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));
}
}
@@ -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>>;
@@ -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));
}
}
@@ -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>>;
@@ -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));
}
}
@@ -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.
}
}
@@ -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>>;
@@ -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;
}
}
@@ -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>>;
@@ -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));
}
}
@@ -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);
}
}
@@ -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>>;
@@ -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.");
}
}
@@ -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>>;
@@ -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
};
}
}
@@ -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);
}