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
+29 -4
View File
@@ -81,15 +81,15 @@ projects/assemblies, Clean-Architecture layers, and cross-layer dependencies.
```
src/
├── Core/
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker)
│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); Search area = the b7 discovery query + admin index-rebuild; + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers)
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), Booking/ (BookingRequest — the money-free pre-payment intent + BookingRequestStatus/BookingRequestTransitions forward-only status guard + CaregiverGender codes), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker)
│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); Search area = the b7 discovery query + admin index-rebuild; Booking area = the b8 booking-request lifecycle (create/accept/reject/cancel + role-scoped inbox/detail + the expiry sweep command); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers)
├── Infrastructure/
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service + Search/ = SearchIndexMaintainer + SqlNurseSearch)
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/ + BookingConfig/), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service + Search/ = SearchIndexMaintainer + SqlNurseSearch + Booking/ = BookingRequestExpiryHostedService)
│ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/)
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier + MockShahkarVerifier + MockIdentityKycProvider + MockCredentialVerifier) + AddCrossCuttingSeams
│ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net
├── API/
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch), appsettings*.json
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch + customer/nurse BookingRequests + admin AdminBookingRequests), appsettings*.json
│ ├── Baya.WebFramework BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting)
│ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models (User only)
├── Shared/Baya.SharedKernel Extensions + validation base
@@ -226,6 +226,31 @@ Two seams live in `Application/Contracts/Search/`, registered by `AddPersistence
row on re-upsert so each (variant × area) has exactly one live row.
- **Incremental maintenance and full rebuild must converge** — the index is fully re-derivable from source.
**Booking requests — pre-payment intent (backend-phase-8).** A new **`booking` schema** holds the single
table `BookingRequests` — the **money-free** first half of the engagement lifecycle (`bookings` + money are
b9/b10). One customer requests one nurse for a patient/variant/address/date; the nurse accepts (opening a
30-minute payment window) or rejects before a frozen response deadline; unanswered/unpaid requests auto-expire.
Features under `Baya.Application/Features/Booking/{Commands|Queries}/`; config in
`Persistence/Configuration/BookingConfig/`; per-domain repo (`IBookingRequestRepository`) on `IUnitOfWork`;
the recurring sweep is `Persistence/Services/Booking/BookingRequestExpiryHostedService` (reuses the b1
`IJobScheduler`/`BackgroundService` seam). Load-bearing rules:
- **No money, ever, and no `bookings` row.** A request carries no price/total; accept only opens the payment
window. b9 consumes an `accepted_awaiting_payment` request → creates the booking → sets it `converted`.
- **Two-stage clinical disclosure (stage 1).** The nurse sees **only** the unencrypted, limited `customer_notes`
(never routed through `IFieldEncryptor`); the nurse view of a request **masks the full address** (line/postal/
recipient) to a coarse city/district. The encrypted `booking_care_instructions` are b9's stage 2.
- **Tenancy invariant.** patient + address ∈ the caller's `customer_id`; variant ∈ the requested `nurse_id`.
Resolved from `ICurrentUser`, never the body; a mismatch is a clean 404.
- **Same-gender match at request time.** `required_caregiver_gender` (`male`/`female`/`any`) is matched against
the nurse's `User.Gender`; required on create, never silently defaulted.
- **Deadlines frozen from config.** `nurse_response_deadline_at` = `now + nurse_response_deadline_hours` at
create; `payment_deadline_at` = `now + booking_payment_deadline_minutes` (30) at accept — both stored as
absolute UTC `datetime2` so a later config change can't move them. Stored as `DateTime` (not `DateTimeOffset`)
because they are compared/sorted in queries and the SQLite test provider can't translate `DateTimeOffset`.
- **Forward-only status guard** (`BookingRequestTransitions`) — every write is pre-checked; an illegal edge is a
409, terminal states have no outgoing edge; the expiry sweep's `WHERE status = …` predicate is the concurrency
guard (a row a racing accept/cancel moved is simply not reloaded). See CONVENTIONS §6.
**Keeping the Project map current.** When a change touches the architecture — adds, removes, or
renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer
dependency — you **must** update this Project map (and the dependency rule above, if affected) in the
+24
View File
@@ -332,6 +332,30 @@ rules this establishes:
and the wire carry `in_review`, not `InReview`. Enum→code mapping in a projected read happens **in memory
after materialization** (`.ToCode()` is not LINQ-translatable); DTOs expose the code string.
### Forward-only status machine (backend-phase-8)
When an entity has a lifecycle `status` with a fixed set of allowed transitions, model the machine as a
**static allowed-edges table** and route **every** write through it — never assign `status` ad-hoc. The b8
pattern (reused by b9 for the `bookings` machine):
- **Statuses are `const string` codes** (`BookingRequestStatus`) persisted as the stable snake_case string —
no C# enum, no value converter needed. **Edges live in a static `CanTransition(from, to)`**
(`BookingRequestTransitions`) built from a `Dictionary<string, IReadOnlyCollection<string>>`; terminal
states map to an empty set.
- **The entity owns the transition.** `status` has a **private setter**; the only mutators are cohesive domain
methods (`Accept`/`Reject`/`Cancel…`) that call a private `Transition(target)` which asserts the edge is
legal (throws on an illegal edge — a programming error, since the handler pre-checks). Side-effect fields
(`payment_deadline_at`, `rejection_reason`) are set in the same method.
- **The handler pre-checks and returns a clean 409.** `if (!entity.CanTransitionTo(target)) return
OperationResult.ConflictResult(...)` — never throw for the expected "already moved / terminal" case.
- **Time-sensitive commands self-guard** against a passed deadline via `IDateTimeProvider` rather than trusting
a sweep has run; the recurring expiry `BackgroundService` is bounded/paginated/idempotent, and its
`WHERE status = …` predicate (re-queried each tick) is the concurrency guard — a row a racing action moved is
simply not reloaded.
- **Deadline columns that are compared/sorted use `DateTime` (UTC `datetime2`), not `DateTimeOffset`** — the
SQLite test provider cannot translate `DateTimeOffset` comparison/`ORDER BY`. Order lists/sweeps by `Id`, not
the timestamp, for the same reason.
---
## 7. Validation
@@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Booking.Commands.ExpireBookingRequests;
using Baya.Application.Models.Booking;
using Baya.Infrastructure.Identity.Identity.PermissionManager;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1;
/// <summary>
/// Admin manual trigger for the booking-request expiry sweep — fires the same idempotent command the
/// recurring <c>BackgroundService</c> runs, so a human/test can expire stale requests on demand without
/// waiting for the interval.
/// </summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Authorize(ConstantPolicies.DynamicPermission)]
[Display(Description = "Admin booking-request maintenance (manual expiry sweep)")]
public sealed class AdminBookingRequestsController(ISender sender) : BaseController
{
[HttpPost("[action]")]
[ProducesOkApiResponseType<ExpireBookingRequestsResult>]
public async Task<IActionResult> Expire(CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new ExpireBookingRequestsCommand(), cancellationToken));
}
@@ -0,0 +1,60 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Booking.Commands.AcceptBookingRequest;
using Baya.Application.Features.Booking.Commands.CancelBookingRequest;
using Baya.Application.Features.Booking.Commands.CreateBookingRequest;
using Baya.Application.Features.Booking.Commands.RejectBookingRequest;
using Baya.Application.Features.Booking.Queries.GetBookingRequest;
using Baya.Application.Features.Booking.Queries.ListBookingRequests;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1;
/// <summary>
/// The pre-payment booking-request lifecycle: a customer creates/cancels a request; the assigned nurse
/// accepts/rejects; both parties read their role-scoped inbox and a single request. No money and no
/// <c>bookings</c> row exist here — accept only opens the 30-minute payment window (converted in b9).
/// </summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Authorize]
[Display(Description = "Pre-payment booking requests (create/accept/reject/cancel + role-scoped inbox)")]
public sealed class BookingRequestsController(ISender sender) : BaseController
{
[HttpPost("[action]")]
[ProducesOkApiResponseType<BookingRequestDto>]
public async Task<IActionResult> Create(CreateBookingRequestCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType<BookingRequestDto>]
public async Task<IActionResult> Cancel(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new CancelBookingRequestCommand(id), cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType<BookingRequestDto>]
public async Task<IActionResult> Accept(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new AcceptBookingRequestCommand(id), cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType<BookingRequestDto>]
public async Task<IActionResult> Reject(long id, RejectBookingRequestCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
[HttpGet("[action]")]
[ProducesOkApiResponseType<PagedResult<BookingRequestListItemDto>>]
public async Task<IActionResult> List([FromQuery] ListBookingRequestsQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
[HttpGet("[action]/{id}")]
[ProducesOkApiResponseType<BookingRequestDto>]
public async Task<IActionResult> Get(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetBookingRequestQuery(id), cancellationToken));
}
@@ -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);
}
@@ -0,0 +1,55 @@
using Baya.Domain.Entities.Booking;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.BookingConfig;
internal sealed class BookingRequestConfig : IEntityTypeConfiguration<BookingRequest>
{
public void Configure(EntityTypeBuilder<BookingRequest> builder)
{
builder.ToTable("BookingRequests", "booking");
// required_caregiver_gender is a closed code set (male/female/any); customer_notes is DELIBERATELY
// unencrypted, limited stage-1 clinical context — never routed through the field encryptor.
builder.Property(r => r.RequiredCaregiverGender).HasMaxLength(10);
builder.Property(r => r.CustomerNotes).HasMaxLength(1000);
builder.Property(r => r.Status).HasMaxLength(50).IsRequired();
builder.Property(r => r.NurseRejectionReason).HasMaxLength(500);
builder.Property(r => r.NurseResponseDeadlineAt).IsRequired();
// Inbox lists read on (party, status), actionable-first; the two deadline indexes let the expiry
// sweep select stale rows through a covering index instead of scanning the table.
builder.HasIndex(r => new { r.NurseId, r.Status });
builder.HasIndex(r => new { r.CustomerId, r.Status });
builder.HasIndex(r => new { r.Status, r.NurseResponseDeadlineAt });
builder.HasIndex(r => new { r.Status, r.PaymentDeadlineAt });
builder.HasOne(r => r.Customer)
.WithMany()
.HasForeignKey(r => r.CustomerId)
.IsRequired();
builder.HasOne(r => r.Nurse)
.WithMany()
.HasForeignKey(r => r.NurseId)
.IsRequired();
builder.HasOne(r => r.Patient)
.WithMany()
.HasForeignKey(r => r.PatientId)
.IsRequired();
builder.HasOne(r => r.Variant)
.WithMany()
.HasForeignKey(r => r.VariantId)
.IsRequired();
builder.HasOne(r => r.CustomerAddress)
.WithMany()
.HasForeignKey(r => r.CustomerAddressId)
.IsRequired();
builder.HasQueryFilter(r => r.DeletedAt == null);
}
}
@@ -0,0 +1,135 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Baya.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class BookingRequests : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "booking");
migrationBuilder.CreateTable(
name: "BookingRequests",
schema: "booking",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
CustomerId = table.Column<long>(type: "bigint", nullable: false),
NurseId = table.Column<long>(type: "bigint", nullable: false),
PatientId = table.Column<long>(type: "bigint", nullable: false),
VariantId = table.Column<long>(type: "bigint", nullable: false),
CustomerAddressId = table.Column<long>(type: "bigint", nullable: false),
RequiredCaregiverGender = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: true),
RequestedDate = table.Column<DateOnly>(type: "date", nullable: false),
RequestedTimeStart = table.Column<TimeOnly>(type: "time", nullable: false),
RequestedTimeEnd = table.Column<TimeOnly>(type: "time", nullable: false),
CustomerNotes = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
Status = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
NurseResponseDeadlineAt = table.Column<DateTime>(type: "datetime2", nullable: false),
PaymentDeadlineAt = table.Column<DateTime>(type: "datetime2", nullable: true),
NurseRejectionReason = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_BookingRequests", x => x.Id);
table.ForeignKey(
name: "FK_BookingRequests_CustomerAddresses_CustomerAddressId",
column: x => x.CustomerAddressId,
principalSchema: "usr",
principalTable: "CustomerAddresses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_BookingRequests_CustomerProfiles_CustomerId",
column: x => x.CustomerId,
principalSchema: "usr",
principalTable: "CustomerProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_BookingRequests_NurseProfiles_NurseId",
column: x => x.NurseId,
principalSchema: "usr",
principalTable: "NurseProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_BookingRequests_NurseServiceVariants_VariantId",
column: x => x.VariantId,
principalSchema: "catalog",
principalTable: "NurseServiceVariants",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_BookingRequests_Patients_PatientId",
column: x => x.PatientId,
principalSchema: "usr",
principalTable: "Patients",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_BookingRequests_CustomerAddressId",
schema: "booking",
table: "BookingRequests",
column: "CustomerAddressId");
migrationBuilder.CreateIndex(
name: "IX_BookingRequests_CustomerId_Status",
schema: "booking",
table: "BookingRequests",
columns: new[] { "CustomerId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_BookingRequests_NurseId_Status",
schema: "booking",
table: "BookingRequests",
columns: new[] { "NurseId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_BookingRequests_PatientId",
schema: "booking",
table: "BookingRequests",
column: "PatientId");
migrationBuilder.CreateIndex(
name: "IX_BookingRequests_Status_NurseResponseDeadlineAt",
schema: "booking",
table: "BookingRequests",
columns: new[] { "Status", "NurseResponseDeadlineAt" });
migrationBuilder.CreateIndex(
name: "IX_BookingRequests_Status_PaymentDeadlineAt",
schema: "booking",
table: "BookingRequests",
columns: new[] { "Status", "PaymentDeadlineAt" });
migrationBuilder.CreateIndex(
name: "IX_BookingRequests_VariantId",
schema: "booking",
table: "BookingRequests",
column: "VariantId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BookingRequests",
schema: "booking");
}
}
}
@@ -98,6 +98,95 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.ToTable("AuditLogs", "ops");
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingRequest", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<long>("CustomerAddressId")
.HasColumnType("bigint");
b.Property<long>("CustomerId")
.HasColumnType("bigint");
b.Property<string>("CustomerNotes")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("NurseId")
.HasColumnType("bigint");
b.Property<string>("NurseRejectionReason")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime>("NurseResponseDeadlineAt")
.HasColumnType("datetime2");
b.Property<long>("PatientId")
.HasColumnType("bigint");
b.Property<DateTime?>("PaymentDeadlineAt")
.HasColumnType("datetime2");
b.Property<DateOnly>("RequestedDate")
.HasColumnType("date");
b.Property<TimeOnly>("RequestedTimeEnd")
.HasColumnType("time");
b.Property<TimeOnly>("RequestedTimeStart")
.HasColumnType("time");
b.Property<string>("RequiredCaregiverGender")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<long>("VariantId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("CustomerAddressId");
b.HasIndex("PatientId");
b.HasIndex("VariantId");
b.HasIndex("CustomerId", "Status");
b.HasIndex("NurseId", "Status");
b.HasIndex("Status", "NurseResponseDeadlineAt");
b.HasIndex("Status", "PaymentDeadlineAt");
b.ToTable("BookingRequests", "booking");
});
modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b =>
{
b.Property<long>("Id")
@@ -3076,6 +3165,49 @@ namespace Baya.Infrastructure.Persistence.Migrations
.HasForeignKey("ActorUserId");
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingRequest", b =>
{
b.HasOne("Baya.Domain.Entities.Identity.CustomerAddress", "CustomerAddress")
.WithMany()
.HasForeignKey("CustomerAddressId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer")
.WithMany()
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse")
.WithMany()
.HasForeignKey("NurseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Identity.Patient", "Patient")
.WithMany()
.HasForeignKey("PatientId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant")
.WithMany()
.HasForeignKey("VariantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Customer");
b.Navigation("CustomerAddress");
b.Navigation("Nurse");
b.Navigation("Patient");
b.Navigation("Variant");
});
modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b =>
{
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse")
@@ -0,0 +1,197 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class BookingRequestRepository : BaseAsyncRepository<BookingRequest>, IBookingRequestRepository
{
public BookingRequestRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task AddAsync(BookingRequest request, CancellationToken cancellationToken)
=> base.AddAsync(request);
public Task<BookingRequest?> GetTrackedForCustomerAsync(long id, long customerId, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(r => r.Id == id && r.CustomerId == customerId, cancellationToken);
public Task<BookingRequest?> GetTrackedForNurseAsync(long id, long nurseId, CancellationToken cancellationToken)
=> Table
.Include(r => r.Customer)
.FirstOrDefaultAsync(r => r.Id == id && r.NurseId == nurseId, cancellationToken);
public async Task<IReadOnlyList<BookingRequest>> GetStalePendingAsync(DateTime now, int batchSize, CancellationToken cancellationToken)
=> await Table
.Include(r => r.Customer)
.Where(r => r.Status == BookingRequestStatus.PendingNurseResponse && r.NurseResponseDeadlineAt <= now)
// Order by id (not the deadline): the whole batch is stale, order is irrelevant for correctness,
// and DateTimeOffset is not sortable on the SQLite test provider.
.OrderBy(r => r.Id)
.Take(batchSize)
.ToListAsync(cancellationToken);
public async Task<IReadOnlyList<BookingRequest>> GetStaleAcceptedAsync(DateTime now, int batchSize, CancellationToken cancellationToken)
=> await Table
.Include(r => r.Customer)
.Where(r => r.Status == BookingRequestStatus.AcceptedAwaitingPayment
&& r.PaymentDeadlineAt != null && r.PaymentDeadlineAt <= now)
.OrderBy(r => r.Id)
.Take(batchSize)
.ToListAsync(cancellationToken);
public async Task<PagedResult<BookingRequestListItemDto>> ListForCustomerAsync(
long customerId, string? status, int page, int pageSize, CancellationToken cancellationToken)
{
var query = TableNoTracking.Where(r => r.CustomerId == customerId);
if (!string.IsNullOrWhiteSpace(status))
query = query.Where(r => r.Status == status);
var total = await query.CountAsync(cancellationToken);
var rows = await OrderActionableFirst(query)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(r => new CustomerRow(
r.Id,
r.Status,
r.Nurse.User.Name,
r.Nurse.User.FamilyName,
r.Nurse.AverageRating,
r.RequiredCaregiverGender,
r.RequestedDate,
r.RequestedTimeStart,
r.RequestedTimeEnd,
r.NurseResponseDeadlineAt,
r.PaymentDeadlineAt))
.ToListAsync(cancellationToken);
var items = rows
.Select(r => new BookingRequestListItemDto(
r.Id, r.Status, ComposeName(r.NurseName, r.NurseFamily), r.NurseRating,
r.RequiredCaregiverGender, r.RequestedDate, r.RequestedTimeStart, r.RequestedTimeEnd,
r.NurseResponseDeadlineAt, r.PaymentDeadlineAt, CustomerNotes: null))
.ToList();
return new PagedResult<BookingRequestListItemDto>(items, total, page, pageSize);
}
public async Task<PagedResult<BookingRequestListItemDto>> ListForNurseAsync(
long nurseId, string? status, int page, int pageSize, CancellationToken cancellationToken)
{
var query = TableNoTracking.Where(r => r.NurseId == nurseId);
if (!string.IsNullOrWhiteSpace(status))
query = query.Where(r => r.Status == status);
var total = await query.CountAsync(cancellationToken);
var items = await OrderActionableFirst(query)
.Skip((page - 1) * pageSize)
.Take(pageSize)
// Nurse inbox: counterparty is the patient, and stage-1 customer_notes is the ONLY clinical
// context surfaced. No encrypted/care field is projected here, ever.
.Select(r => new BookingRequestListItemDto(
r.Id,
r.Status,
r.Patient.DisplayName,
null,
r.RequiredCaregiverGender,
r.RequestedDate,
r.RequestedTimeStart,
r.RequestedTimeEnd,
r.NurseResponseDeadlineAt,
r.PaymentDeadlineAt,
r.CustomerNotes))
.ToListAsync(cancellationToken);
return new PagedResult<BookingRequestListItemDto>(items, total, page, pageSize);
}
public async Task<BookingRequestDetailProjection?> GetDetailAsync(long id, CancellationToken cancellationToken)
{
var row = await TableNoTracking
.Where(r => r.Id == id)
.Select(r => new DetailRow(
r.Id,
r.Status,
r.CustomerId,
r.NurseId,
r.Nurse.User.Name,
r.Nurse.User.FamilyName,
r.Nurse.AverageRating,
r.Nurse.TotalReviews,
r.PatientId,
r.Patient.DisplayName,
r.VariantId,
r.Variant.DisplayName,
r.Variant.PriceUnit,
r.CustomerAddressId,
r.CustomerAddress.Title,
r.CustomerAddress.CityId,
r.CustomerAddress.City.NameFa,
r.CustomerAddress.City.NameEn,
r.CustomerAddress.DistrictId,
r.CustomerAddress.DistrictId == null ? null : r.CustomerAddress.District.NameFa,
r.CustomerAddress.DistrictId == null ? null : r.CustomerAddress.District.NameEn,
r.CustomerAddress.AddressLine,
r.CustomerAddress.PostalCode,
r.CustomerAddress.RecipientName,
r.CustomerAddress.RecipientPhone,
r.RequiredCaregiverGender,
r.RequestedDate,
r.RequestedTimeStart,
r.RequestedTimeEnd,
r.CustomerNotes,
r.NurseResponseDeadlineAt,
r.PaymentDeadlineAt,
r.NurseRejectionReason,
r.CreatedAt))
.FirstOrDefaultAsync(cancellationToken);
if (row is null)
return null;
return new BookingRequestDetailProjection(
row.Id, row.Status, row.CustomerId, row.NurseId,
ComposeName(row.NurseName, row.NurseFamily), row.NurseRating, row.NurseTotalReviews,
row.PatientId, row.PatientName,
row.VariantId, row.VariantLabel, row.VariantPriceUnit,
row.CustomerAddressId, row.AddressTitle, row.CityId, row.CityNameFa, row.CityNameEn,
row.DistrictId, row.DistrictNameFa, row.DistrictNameEn,
row.AddressLine, row.PostalCode, row.RecipientName, row.RecipientPhone,
row.RequiredCaregiverGender, row.RequestedDate, row.RequestedTimeStart, row.RequestedTimeEnd,
row.CustomerNotes, row.NurseResponseDeadlineAt, row.PaymentDeadlineAt,
row.NurseRejectionReason, row.CreatedAt);
}
// Actionable (awaiting a party's action) rows float above terminal ones, then most-recent first. Recency
// (id) rather than the deadline is the secondary key: for a given status the deadline tracks creation
// time anyway, and DateTimeOffset is not sortable on the SQLite test provider.
private static IQueryable<BookingRequest> OrderActionableFirst(IQueryable<BookingRequest> query)
=> query
.OrderByDescending(r => r.Status == BookingRequestStatus.PendingNurseResponse
|| r.Status == BookingRequestStatus.AcceptedAwaitingPayment)
.ThenByDescending(r => r.Id);
private static string ComposeName(string? name, string? familyName)
=> string.Join(' ', new[] { name, familyName }.Where(s => !string.IsNullOrWhiteSpace(s))).Trim();
private sealed record CustomerRow(
long Id, string Status, string? NurseName, string? NurseFamily, decimal NurseRating,
string? RequiredCaregiverGender, DateOnly RequestedDate, TimeOnly RequestedTimeStart, TimeOnly RequestedTimeEnd,
DateTime NurseResponseDeadlineAt, DateTime? PaymentDeadlineAt);
private sealed record DetailRow(
long Id, string Status, long CustomerId, long NurseId,
string? NurseName, string? NurseFamily, 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);
}
@@ -19,6 +19,7 @@ public class UnitOfWork : IUnitOfWork
public ICatalogRepository CatalogRepository { get; }
public INurseServiceVariantRepository NurseServiceVariantRepository { get; }
public IVerificationRepository VerificationRepository { get; }
public IBookingRequestRepository BookingRequestRepository { get; }
public UnitOfWork(ApplicationDbContext db)
{
@@ -36,6 +37,7 @@ public class UnitOfWork : IUnitOfWork
CatalogRepository = new CatalogRepository(_db);
NurseServiceVariantRepository = new NurseServiceVariantRepository(_db);
VerificationRepository = new VerificationRepository(_db);
BookingRequestRepository = new BookingRequestRepository(_db);
}
public Task CommitAsync()
@@ -49,4 +49,10 @@ internal sealed class NurseProfileRepository : BaseAsyncRepository<NurseProfile>
.Where(p => p.UserId == userId)
.Select(p => new NurseIdentityContext(p.Id, p.User.NationalId))
.FirstOrDefaultAsync(cancellationToken);
public Task<NurseBookingContext> GetBookingContextByIdAsync(long nurseProfileId, CancellationToken cancellationToken)
=> TableNoTracking
.Where(p => p.Id == nurseProfileId)
.Select(p => new NurseBookingContext(p.UserId, p.User.Gender, p.IsVerified, p.IsAcceptingBookings))
.FirstOrDefaultAsync(cancellationToken);
}
@@ -11,6 +11,7 @@ using Baya.Infrastructure.Persistence.Interceptors;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Baya.Infrastructure.Persistence.Services.Analytics;
using Baya.Infrastructure.Persistence.Services.Audit;
using Baya.Infrastructure.Persistence.Services.Booking;
using Baya.Infrastructure.Persistence.Services.Configuration;
using Baya.Infrastructure.Persistence.Services.Holidays;
using Baya.Infrastructure.Persistence.Services.Notifications;
@@ -53,6 +54,10 @@ public static class ServiceCollectionExtensions
// Retention job seam (mock = in-process interval runner; real Hangfire/Quartz deferred).
services.AddHostedService<NotificationRetentionHostedService>();
// Booking-request expiry sweep (same in-process interval-runner seam): auto-expires stale
// pending/awaiting-payment requests. Also reachable via the admin manual-trigger endpoint.
services.AddHostedService<BookingRequestExpiryHostedService>();
// Search (backend-phase-7). The index maintainer keeps nurse_search_index consistent inline inside
// each source write's unit of work. The INurseSearch backend is config-selected — SQL is the real
// MVP backend; a later ElasticNurseSearch drops in here with no caller change.
@@ -0,0 +1,53 @@
#nullable enable
using Baya.Application.Features.Booking.Commands.ExpireBookingRequests;
using Mediator;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Booking;
/// <summary>
/// The recurring expiry sweep for <c>booking_requests</c> (reuses the b1 in-process interval-runner seam;
/// real Hangfire/Quartz is deferred). Each tick sends <see cref="ExpireBookingRequestsCommand"/>, which
/// transitions stale rows (<c>expired_no_response</c> / <c>payment_deadline_expired</c>) in bounded,
/// idempotent batches. The interval is short because the payment window is only 30 minutes; there is no b1
/// interval config key for booking expiry, so it is a documented constant.
/// </summary>
internal sealed class BookingRequestExpiryHostedService(
IServiceScopeFactory scopeFactory,
ILogger<BookingRequestExpiryHostedService> logger) : BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(1);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await SweepSafely(stoppingToken);
using var timer = new PeriodicTimer(Interval);
while (await timer.WaitForNextTickAsync(stoppingToken))
await SweepSafely(stoppingToken);
}
private async Task SweepSafely(CancellationToken cancellationToken)
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var sender = scope.ServiceProvider.GetRequiredService<ISender>();
var result = await sender.Send(new ExpireBookingRequestsCommand(), cancellationToken);
if (result.IsSuccess && result.Result is { } counts && (counts.ExpiredNoResponse > 0 || counts.PaymentDeadlineExpired > 0))
logger.LogInformation(
"Booking-request expiry swept {NoResponse} expired-no-response and {PaymentExpired} payment-window-expired requests",
counts.ExpiredNoResponse, counts.PaymentDeadlineExpired);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Host is shutting down — expected, don't log as an error.
}
catch (Exception ex)
{
logger.LogError(ex, "Booking-request expiry sweep failed");
}
}
}
@@ -0,0 +1,78 @@
using System.Net;
using System.Net.Http.Json;
namespace Baya.Test.Api;
public class BookingRequestsApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
private static object ValidShapeButBadGender() => new
{
nurseId = 1,
variantId = 1,
patientId = 1,
customerAddressId = 1,
requestedDate = "2027-01-01",
requestedTimeStart = "09:00:00",
requestedTimeEnd = "13:00:00",
requiredCaregiverGender = "other",
customerNotes = "x"
};
[Fact]
public async Task Create_Unauthenticated_Returns401()
{
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/v1/booking_requests/create", ValidShapeButBadGender());
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Create_InvalidGender_Returns400()
{
var client = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, client, "09131000101", "customer");
var response = await client.PostAsJsonAsync("/api/v1/booking_requests/create", ValidShapeButBadGender());
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task List_Authenticated_ReturnsEmptyPagedEnvelope()
{
var client = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, client, "09131000102", "customer");
var response = await client.GetAsync("/api/v1/booking_requests/list");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var data = await AuthTestClient.ReadDataAsync(response);
Assert.Equal(0, data.GetProperty("total").GetInt32());
}
[Fact]
public async Task Expire_Unauthenticated_Returns401()
{
var client = factory.CreateClient();
var response = await client.PostAsync("/api/v1/admin_booking_requests/expire", null);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Expire_AsAdmin_ReturnsZeroCountsOnEmptySet()
{
var admin = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, admin, "09131000103");
var response = await admin.PostAsync("/api/v1/admin_booking_requests/expire", null);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var data = await AuthTestClient.ReadDataAsync(response);
Assert.Equal(0, data.GetProperty("expiredNoResponse").GetInt32());
Assert.Equal(0, data.GetProperty("paymentDeadlineExpired").GetInt32());
}
}
@@ -0,0 +1,59 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Features.Booking.Commands.ExpireBookingRequests;
using Baya.Domain.Entities.Booking;
using NSubstitute;
namespace Baya.Test.Foundation.Booking;
public class BookingRequestExpiryTests : IDisposable
{
private readonly BookingTestHost _host = new();
private readonly IDateTimeProvider _clock = Substitute.For<IDateTimeProvider>();
private readonly INotificationDispatcher _notifications = Substitute.For<INotificationDispatcher>();
private static readonly DateTimeOffset Now = new(2026, 7, 6, 12, 0, 0, TimeSpan.Zero);
private static readonly DateTime NowUtc = Now.UtcDateTime;
private ExpireBookingRequestsCommandHandler Handler()
{
_clock.UtcNow.Returns(Now);
return new ExpireBookingRequestsCommandHandler(_host.UnitOfWork, _clock, _notifications);
}
[Fact]
public async Task Sweep_TransitionsStalePendingAndAcceptedAndNotifies()
{
var stalePending = _host.AddPendingRequest(responseDeadline: NowUtc.AddHours(-1));
var freshPending = _host.AddPendingRequest(responseDeadline: NowUtc.AddHours(5));
var staleAccepted = _host.AddAcceptedRequest(paymentDeadline: NowUtc.AddMinutes(-1));
var freshAccepted = _host.AddAcceptedRequest(paymentDeadline: NowUtc.AddMinutes(20));
var result = await Handler().Handle(new ExpireBookingRequestsCommand(), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(1, result.Result.ExpiredNoResponse);
Assert.Equal(1, result.Result.PaymentDeadlineExpired);
Assert.Equal(BookingRequestStatus.ExpiredNoResponse, _host.StatusOf(stalePending.Id));
Assert.Equal(BookingRequestStatus.PendingNurseResponse, _host.StatusOf(freshPending.Id));
Assert.Equal(BookingRequestStatus.PaymentDeadlineExpired, _host.StatusOf(staleAccepted.Id));
Assert.Equal(BookingRequestStatus.AcceptedAwaitingPayment, _host.StatusOf(freshAccepted.Id));
await _notifications.Received(2).DispatchAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Sweep_IsIdempotent_SecondRunIsNoOp()
{
_host.AddPendingRequest(responseDeadline: NowUtc.AddHours(-1));
var first = await Handler().Handle(new ExpireBookingRequestsCommand(), CancellationToken.None);
var second = await Handler().Handle(new ExpireBookingRequestsCommand(), CancellationToken.None);
Assert.Equal(1, first.Result.ExpiredNoResponse);
Assert.Equal(0, second.Result.ExpiredNoResponse);
Assert.Equal(0, second.Result.PaymentDeadlineExpired);
}
public void Dispose() => _host.Dispose();
}
@@ -0,0 +1,97 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Features.Booking.Queries.GetBookingRequest;
using Baya.Application.Features.Booking.Queries.ListBookingRequests;
using Baya.Domain.Entities.User;
using NSubstitute;
namespace Baya.Test.Foundation.Booking;
public class BookingRequestQueryTests : IDisposable
{
private readonly BookingTestHost _host = new();
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
private void SignInAs(int userId, params string[] roles)
{
_currentUser.UserId.Returns(userId);
_currentUser.Roles.Returns(roles);
}
[Fact]
public async Task List_NurseInbox_ShowsPatientCounterpartyAndCustomerNotesOnly()
{
_host.AddPendingRequest();
SignInAs(_host.NurseUserId, RoleNames.Nurse);
var handler = new ListBookingRequestsQueryHandler(_currentUser, _host.UnitOfWork);
var result = await handler.Handle(new ListBookingRequestsQuery(), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(1, result.Result.Total);
var item = result.Result.Items[0];
Assert.Contains("پدر", item.CounterpartyName);
Assert.Equal("Careful with the IV.", item.CustomerNotes);
Assert.Null(item.NurseRating);
}
[Fact]
public async Task List_CustomerInbox_ShowsNurseCounterpartyWithoutCustomerNotes()
{
_host.AddPendingRequest();
SignInAs(_host.CustomerUserId, RoleNames.Customer);
var handler = new ListBookingRequestsQueryHandler(_currentUser, _host.UnitOfWork);
var result = await handler.Handle(new ListBookingRequestsQuery(), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(1, result.Result.Total);
var item = result.Result.Items[0];
Assert.Contains("زهرا", item.CounterpartyName);
Assert.Null(item.CustomerNotes);
Assert.NotNull(item.NurseRating);
}
[Fact]
public async Task Get_AsNurse_MasksFullAddress()
{
var request = _host.AddPendingRequest();
SignInAs(_host.NurseUserId, RoleNames.Nurse);
var handler = new GetBookingRequestQueryHandler(_currentUser, _host.UnitOfWork);
var result = await handler.Handle(new GetBookingRequestQuery(request.Id), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Null(result.Result.AddressLine);
Assert.Null(result.Result.PostalCode);
// Coarse location + stage-1 notes are still available to the nurse.
Assert.Equal("Careful with the IV.", result.Result.CustomerNotes);
Assert.False(string.IsNullOrEmpty(result.Result.CityNameFa));
}
[Fact]
public async Task Get_AsOwningCustomer_ReturnsFullAddress()
{
var request = _host.AddPendingRequest();
SignInAs(_host.CustomerUserId, RoleNames.Customer);
var handler = new GetBookingRequestQueryHandler(_currentUser, _host.UnitOfWork);
var result = await handler.Handle(new GetBookingRequestQuery(request.Id), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.False(string.IsNullOrEmpty(result.Result.AddressLine));
}
[Fact]
public async Task Get_AsThirdParty_IsNotFound()
{
var request = _host.AddPendingRequest();
SignInAs(999999, RoleNames.Customer);
var handler = new GetBookingRequestQueryHandler(_currentUser, _host.UnitOfWork);
var result = await handler.Handle(new GetBookingRequestQuery(request.Id), CancellationToken.None);
Assert.True(result.IsNotFound);
}
public void Dispose() => _host.Dispose();
}
@@ -0,0 +1,49 @@
using Baya.Domain.Entities.Booking;
namespace Baya.Test.Foundation.Booking;
public class BookingRequestTransitionsTests
{
[Theory]
[InlineData(BookingRequestStatus.PendingNurseResponse, BookingRequestStatus.AcceptedAwaitingPayment, true)]
[InlineData(BookingRequestStatus.PendingNurseResponse, BookingRequestStatus.RejectedByNurse, true)]
[InlineData(BookingRequestStatus.PendingNurseResponse, BookingRequestStatus.ExpiredNoResponse, true)]
[InlineData(BookingRequestStatus.PendingNurseResponse, BookingRequestStatus.CancelledByCustomer, true)]
[InlineData(BookingRequestStatus.AcceptedAwaitingPayment, BookingRequestStatus.Converted, true)]
[InlineData(BookingRequestStatus.AcceptedAwaitingPayment, BookingRequestStatus.PaymentDeadlineExpired, true)]
[InlineData(BookingRequestStatus.AcceptedAwaitingPayment, BookingRequestStatus.CancelledByCustomer, true)]
// Illegal edges.
[InlineData(BookingRequestStatus.PendingNurseResponse, BookingRequestStatus.Converted, false)]
[InlineData(BookingRequestStatus.PendingNurseResponse, BookingRequestStatus.PaymentDeadlineExpired, false)]
[InlineData(BookingRequestStatus.AcceptedAwaitingPayment, BookingRequestStatus.RejectedByNurse, false)]
[InlineData(BookingRequestStatus.AcceptedAwaitingPayment, BookingRequestStatus.ExpiredNoResponse, false)]
// Terminals have no outgoing edges.
[InlineData(BookingRequestStatus.Converted, BookingRequestStatus.CancelledByCustomer, false)]
[InlineData(BookingRequestStatus.RejectedByNurse, BookingRequestStatus.AcceptedAwaitingPayment, false)]
[InlineData(BookingRequestStatus.ExpiredNoResponse, BookingRequestStatus.AcceptedAwaitingPayment, false)]
[InlineData(BookingRequestStatus.PaymentDeadlineExpired, BookingRequestStatus.CancelledByCustomer, false)]
[InlineData(BookingRequestStatus.CancelledByCustomer, BookingRequestStatus.AcceptedAwaitingPayment, false)]
public void CanTransition_MatchesForwardOnlyMachine(string from, string to, bool expected)
=> Assert.Equal(expected, BookingRequestTransitions.CanTransition(from, to));
[Fact]
public void Accept_FromPending_SetsPaymentDeadlineAndStatus()
{
var request = new BookingRequest { NurseResponseDeadlineAt = DateTime.UnixEpoch.AddHours(24) };
var deadline = DateTime.UnixEpoch.AddMinutes(30);
request.Accept(deadline);
Assert.Equal(BookingRequestStatus.AcceptedAwaitingPayment, request.Status);
Assert.Equal(deadline, request.PaymentDeadlineAt);
}
[Fact]
public void Accept_FromTerminal_Throws()
{
var request = new BookingRequest { NurseResponseDeadlineAt = DateTime.UnixEpoch };
request.Reject("no");
Assert.Throws<InvalidOperationException>(() => request.Accept(DateTime.UnixEpoch));
}
}
@@ -0,0 +1,144 @@
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Catalog;
using Baya.Domain.Entities.Geography;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using Baya.Infrastructure.Persistence;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Baya.Tests.Setup.Setups;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Baya.Test.Foundation.Booking;
/// <summary>
/// A self-contained SQLite host exercising the real EF model (schema, filtered indexes, query filters) for
/// the booking-request repository, expiry sweep, and queries end-to-end. Seeds one customer (user + profile
/// + patient + address) and one bookable nurse (user + profile + active variant), and lets a test create
/// requests in a chosen state and drive the real <see cref="UnitOfWork"/>.
/// </summary>
public sealed class BookingTestHost : IDisposable
{
private readonly SqliteConnection _connection;
public ApplicationDbContext Db { get; }
public UnitOfWork UnitOfWork { get; }
public long CustomerId { get; }
public int CustomerUserId { get; }
public long NurseId { get; }
public int NurseUserId { get; }
public long PatientId { get; }
public long AddressId { get; }
public long VariantId { get; }
public BookingTestHost()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(_connection)
.Options;
Db = new ApplicationDbContext(options, TestFieldEncryptor.Instance);
Db.Database.EnsureCreated();
UnitOfWork = new UnitOfWork(Db);
var province = new Province { NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
Db.Set<Province>().Add(province);
Db.SaveChanges();
var city = new City { ProvinceId = province.Id, NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
Db.Set<City>().Add(city);
Db.SaveChanges();
var category = new ServiceCategory { NameFa = "سالمند", NameEn = "Elderly", SortOrder = 1, IsActive = true };
Db.Set<ServiceCategory>().Add(category);
Db.SaveChanges();
var customerUser = new User { UserName = "cust1", PhoneNumber = "09120000001", Gender = "male", Name = "علی", FamilyName = "رضایی", IsActive = true };
Db.Users.Add(customerUser);
Db.SaveChanges();
CustomerUserId = customerUser.Id;
var customer = new CustomerProfile { UserId = customerUser.Id };
Db.Set<CustomerProfile>().Add(customer);
Db.SaveChanges();
CustomerId = customer.Id;
var patient = new Patient { CustomerId = customer.Id, DisplayName = "پدر", FirstName = "حسن", LastName = "رضایی", Gender = "male", IsActive = true };
Db.Set<Patient>().Add(patient);
Db.SaveChanges();
PatientId = patient.Id;
var address = new CustomerAddress
{
CustomerId = customer.Id, CityId = city.Id, Title = "خانه",
AddressLine = "خیابان اول", PostalCode = "1111111111", RecipientName = "علی", RecipientPhone = "09120000001", IsPrimary = true
};
Db.Set<CustomerAddress>().Add(address);
Db.SaveChanges();
AddressId = address.Id;
var nurseUser = new User { UserName = "nurse1", PhoneNumber = "09120000002", Gender = "female", Name = "زهرا", FamilyName = "احمدی", IsActive = true };
Db.Users.Add(nurseUser);
Db.SaveChanges();
NurseUserId = nurseUser.Id;
var nurse = new NurseProfile { UserId = nurseUser.Id };
nurse.MarkVerified();
nurse.SetAcceptingBookings(true);
Db.Set<NurseProfile>().Add(nurse);
Db.SaveChanges();
NurseId = nurse.Id;
var variant = new NurseServiceVariant
{
NurseId = nurse.Id, ServiceCategoryId = category.Id, Price = 5_000_000, PriceUnit = "per_day",
DisplayName = "مراقبت روزانه", OptionSetHash = "hash", IsActive = true
};
Db.Set<NurseServiceVariant>().Add(variant);
Db.SaveChanges();
VariantId = variant.Id;
}
/// <summary>Inserts a pending request with the given response deadline (defaults far in the future).</summary>
public BookingRequest AddPendingRequest(DateTime? responseDeadline = null)
{
var request = new BookingRequest
{
CustomerId = CustomerId,
NurseId = NurseId,
PatientId = PatientId,
VariantId = VariantId,
CustomerAddressId = AddressId,
RequiredCaregiverGender = CaregiverGender.Any,
RequestedDate = new DateOnly(2026, 8, 1),
RequestedTimeStart = new TimeOnly(9, 0),
RequestedTimeEnd = new TimeOnly(13, 0),
CustomerNotes = "Careful with the IV.",
NurseResponseDeadlineAt = responseDeadline ?? new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc)
};
Db.Set<BookingRequest>().Add(request);
Db.SaveChanges();
return request;
}
/// <summary>Inserts an accepted request whose payment window closes at <paramref name="paymentDeadline"/>.</summary>
public BookingRequest AddAcceptedRequest(DateTime paymentDeadline)
{
var request = AddPendingRequest();
request.Accept(paymentDeadline);
Db.SaveChanges();
return request;
}
public string StatusOf(long id)
=> Db.Set<BookingRequest>().AsNoTracking().Where(r => r.Id == id).Select(r => r.Status).Single();
public void Dispose()
{
Db.Dispose();
_connection.Dispose();
}
}
@@ -0,0 +1,192 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Features.Booking.Commands.CreateBookingRequest;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Identity;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Catalog;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using NSubstitute;
namespace Baya.Test.Foundation.Booking;
public class CreateBookingRequestHandlerTests
{
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
private readonly IPlatformConfig _config = Substitute.For<IPlatformConfig>();
private readonly IDateTimeProvider _clock = Substitute.For<IDateTimeProvider>();
private readonly INotificationDispatcher _notifications = Substitute.For<INotificationDispatcher>();
private readonly ICustomerProfileRepository _customers = Substitute.For<ICustomerProfileRepository>();
private readonly IPatientRepository _patients = Substitute.For<IPatientRepository>();
private readonly ICustomerAddressRepository _addresses = Substitute.For<ICustomerAddressRepository>();
private readonly INurseServiceVariantRepository _variants = Substitute.For<INurseServiceVariantRepository>();
private readonly INurseProfileRepository _nurses = Substitute.For<INurseProfileRepository>();
private readonly IBookingRequestRepository _requests = Substitute.For<IBookingRequestRepository>();
private const int CustomerUserId = 7;
private const long CustomerId = 100;
private const long NurseId = 42;
private const int NurseUserId = 9;
private const long PatientId = 5;
private const long AddressId = 6;
private const long VariantId = 8;
private static readonly DateTimeOffset Now = new(2026, 7, 6, 10, 0, 0, TimeSpan.Zero);
public CreateBookingRequestHandlerTests()
{
_currentUser.UserId.Returns(CustomerUserId);
_currentUser.Roles.Returns([RoleNames.Customer]);
_clock.UtcNow.Returns(Now);
_unitOfWork.CustomerProfileRepository.Returns(_customers);
_unitOfWork.PatientRepository.Returns(_patients);
_unitOfWork.CustomerAddressRepository.Returns(_addresses);
_unitOfWork.NurseServiceVariantRepository.Returns(_variants);
_unitOfWork.NurseProfileRepository.Returns(_nurses);
_unitOfWork.BookingRequestRepository.Returns(_requests);
_customers.GetProfileIdByUserIdAsync(CustomerUserId, Arg.Any<CancellationToken>()).Returns(CustomerId);
_patients.GetOwnedAsync(PatientId, CustomerId, Arg.Any<CancellationToken>())
.Returns(new Patient { CustomerId = CustomerId, DisplayName = "پدر", Gender = "male" });
_addresses.GetOwnedAsync(AddressId, CustomerId, Arg.Any<CancellationToken>())
.Returns(new CustomerAddress { CustomerId = CustomerId });
_variants.GetOwnedAsync(VariantId, NurseId, Arg.Any<CancellationToken>())
.Returns(new NurseServiceVariant { NurseId = NurseId, IsActive = true });
_nurses.GetBookingContextByIdAsync(NurseId, Arg.Any<CancellationToken>())
.Returns(new NurseBookingContext(NurseUserId, "female", IsVerified: true, IsAcceptingBookings: true));
_config.GetConfig<int>("nurse_response_deadline_hours", Arg.Any<CancellationToken>()).Returns(24);
_requests.GetDetailAsync(Arg.Any<long>(), Arg.Any<CancellationToken>()).Returns(DetailStub());
}
private CreateBookingRequestCommandHandler Handler()
=> new(_currentUser, _unitOfWork, _config, _clock, _notifications);
private static CreateBookingRequestCommand Command(string gender = "female")
=> new(NurseId, VariantId, PatientId, AddressId,
new DateOnly(2026, 8, 1), new TimeOnly(9, 0), new TimeOnly(13, 0), gender, "Careful with the IV.");
[Fact]
public async Task Create_Valid_FreezesResponseDeadlineFromConfigAndNotifiesNurse()
{
var result = await Handler().Handle(Command(), CancellationToken.None);
Assert.True(result.IsSuccess);
await _requests.Received(1).AddAsync(
Arg.Is<BookingRequest>(r =>
r.CustomerId == CustomerId && r.NurseId == NurseId && r.PatientId == PatientId
&& r.Status == BookingRequestStatus.PendingNurseResponse
&& r.PaymentDeadlineAt == null
&& r.NurseResponseDeadlineAt == Now.AddHours(24).UtcDateTime),
Arg.Any<CancellationToken>());
await _unitOfWork.Received(1).CommitAsync();
await _notifications.Received(1).DispatchAsync(
Arg.Is<Notification>(n => n.RecipientUserId == NurseUserId && n.Type == "booking_request_received"),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Create_AnyGender_MatchesRegardlessOfNurseGender()
{
var result = await Handler().Handle(Command("any"), CancellationToken.None);
Assert.True(result.IsSuccess);
}
[Fact]
public async Task Create_SameGenderMismatch_FailsNamingTheConflict()
{
// Nurse is female; a male requirement must not match.
var result = await Handler().Handle(Command("male"), CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.False(result.IsNotFound);
await _requests.DidNotReceive().AddAsync(Arg.Any<BookingRequest>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Create_CrossCustomerPatient_IsNotFoundAndCreatesNothing()
{
_patients.GetOwnedAsync(PatientId, CustomerId, Arg.Any<CancellationToken>()).Returns((Patient)null);
var result = await Handler().Handle(Command(), CancellationToken.None);
Assert.True(result.IsNotFound);
await _requests.DidNotReceive().AddAsync(Arg.Any<BookingRequest>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Create_AddressNotOwned_IsNotFound()
{
_addresses.GetOwnedAsync(AddressId, CustomerId, Arg.Any<CancellationToken>()).Returns((CustomerAddress)null);
var result = await Handler().Handle(Command(), CancellationToken.None);
Assert.True(result.IsNotFound);
}
[Fact]
public async Task Create_VariantNotThisNurses_IsNotFound()
{
_variants.GetOwnedAsync(VariantId, NurseId, Arg.Any<CancellationToken>()).Returns((NurseServiceVariant)null);
var result = await Handler().Handle(Command(), CancellationToken.None);
Assert.True(result.IsNotFound);
}
[Fact]
public async Task Create_InactiveVariant_Fails()
{
_variants.GetOwnedAsync(VariantId, NurseId, Arg.Any<CancellationToken>())
.Returns(new NurseServiceVariant { NurseId = NurseId, IsActive = false });
var result = await Handler().Handle(Command(), CancellationToken.None);
Assert.False(result.IsSuccess);
await _requests.DidNotReceive().AddAsync(Arg.Any<BookingRequest>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Create_NurseNotAcceptingOrUnverified_Fails()
{
_nurses.GetBookingContextByIdAsync(NurseId, Arg.Any<CancellationToken>())
.Returns(new NurseBookingContext(NurseUserId, "female", IsVerified: true, IsAcceptingBookings: false));
var result = await Handler().Handle(Command(), CancellationToken.None);
Assert.False(result.IsSuccess);
}
[Fact]
public async Task Create_PastDate_Fails()
{
var command = Command() with { RequestedDate = new DateOnly(2026, 7, 5) };
var result = await Handler().Handle(command, CancellationToken.None);
Assert.False(result.IsSuccess);
}
[Fact]
public async Task Create_NonCustomer_IsForbidden()
{
_currentUser.Roles.Returns([RoleNames.Nurse]);
var result = await Handler().Handle(Command(), CancellationToken.None);
Assert.True(result.IsForbidden);
}
private static BookingRequestDetailProjection DetailStub()
=> new(
1, BookingRequestStatus.PendingNurseResponse, CustomerId, NurseId,
"پرستار", 0m, 0, PatientId, "پدر", VariantId, "خدمت", "per_day",
AddressId, "خانه", 1, "تهران", "Tehran", null, null, null,
"line", "postal", "recipient", "phone",
"female", new DateOnly(2026, 8, 1), new TimeOnly(9, 0), new TimeOnly(13, 0),
"notes", Now.AddHours(24).UtcDateTime, null, null, Now);
}
@@ -0,0 +1,199 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Features.Booking.Commands.AcceptBookingRequest;
using Baya.Application.Features.Booking.Commands.CancelBookingRequest;
using Baya.Application.Features.Booking.Commands.RejectBookingRequest;
using Baya.Application.Models.Booking;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using NSubstitute;
namespace Baya.Test.Foundation.Booking;
public class RespondBookingRequestHandlerTests
{
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
private readonly IPlatformConfig _config = Substitute.For<IPlatformConfig>();
private readonly IDateTimeProvider _clock = Substitute.For<IDateTimeProvider>();
private readonly INotificationDispatcher _notifications = Substitute.For<INotificationDispatcher>();
private readonly INurseProfileRepository _nurses = Substitute.For<INurseProfileRepository>();
private readonly ICustomerProfileRepository _customers = Substitute.For<ICustomerProfileRepository>();
private readonly IBookingRequestRepository _requests = Substitute.For<IBookingRequestRepository>();
private const int NurseUserId = 9;
private const long NurseId = 42;
private const int CustomerUserId = 7;
private const long CustomerId = 100;
private const int CustomerAccountUserId = 5;
private const long RequestId = 1;
private static readonly DateTimeOffset Now = new(2026, 7, 6, 10, 0, 0, TimeSpan.Zero);
public RespondBookingRequestHandlerTests()
{
_clock.UtcNow.Returns(Now);
_unitOfWork.NurseProfileRepository.Returns(_nurses);
_unitOfWork.CustomerProfileRepository.Returns(_customers);
_unitOfWork.BookingRequestRepository.Returns(_requests);
_nurses.GetProfileIdByUserIdAsync(NurseUserId, Arg.Any<CancellationToken>()).Returns(NurseId);
_customers.GetProfileIdByUserIdAsync(CustomerUserId, Arg.Any<CancellationToken>()).Returns(CustomerId);
_config.GetConfig<int>("booking_payment_deadline_minutes", Arg.Any<CancellationToken>()).Returns(30);
_requests.GetDetailAsync(Arg.Any<long>(), Arg.Any<CancellationToken>()).Returns(DetailStub(BookingRequestStatus.PendingNurseResponse));
}
private static BookingRequest PendingRequest(DateTimeOffset responseDeadline)
=> new()
{
CustomerId = CustomerId,
NurseId = NurseId,
NurseResponseDeadlineAt = responseDeadline.UtcDateTime,
Customer = new CustomerProfile { UserId = CustomerAccountUserId }
};
// ---- Accept ----
[Fact]
public async Task Accept_Pending_SetsThirtyMinuteWindowAndNotifiesCustomer()
{
_currentUser.UserId.Returns(NurseUserId);
_currentUser.Roles.Returns([RoleNames.Nurse]);
var request = PendingRequest(Now.AddHours(2));
_requests.GetTrackedForNurseAsync(RequestId, NurseId, Arg.Any<CancellationToken>()).Returns(request);
var handler = new AcceptBookingRequestCommandHandler(_currentUser, _unitOfWork, _config, _clock, _notifications);
var result = await handler.Handle(new AcceptBookingRequestCommand(RequestId), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(BookingRequestStatus.AcceptedAwaitingPayment, request.Status);
Assert.Equal(Now.AddMinutes(30).UtcDateTime, request.PaymentDeadlineAt);
await _unitOfWork.Received(1).CommitAsync();
await _notifications.Received(1).DispatchAsync(
Arg.Is<Notification>(n => n.RecipientUserId == CustomerAccountUserId && n.Type == "booking_request_accepted"),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Accept_AfterResponseDeadline_IsConflict()
{
_currentUser.UserId.Returns(NurseUserId);
_currentUser.Roles.Returns([RoleNames.Nurse]);
var request = PendingRequest(Now.AddHours(-1));
_requests.GetTrackedForNurseAsync(RequestId, NurseId, Arg.Any<CancellationToken>()).Returns(request);
var handler = new AcceptBookingRequestCommandHandler(_currentUser, _unitOfWork, _config, _clock, _notifications);
var result = await handler.Handle(new AcceptBookingRequestCommand(RequestId), CancellationToken.None);
Assert.True(result.IsConflict);
await _unitOfWork.DidNotReceive().CommitAsync();
}
[Fact]
public async Task Accept_NonPending_IsConflict()
{
_currentUser.UserId.Returns(NurseUserId);
_currentUser.Roles.Returns([RoleNames.Nurse]);
var request = PendingRequest(Now.AddHours(2));
request.Reject("already declined");
_requests.GetTrackedForNurseAsync(RequestId, NurseId, Arg.Any<CancellationToken>()).Returns(request);
var handler = new AcceptBookingRequestCommandHandler(_currentUser, _unitOfWork, _config, _clock, _notifications);
var result = await handler.Handle(new AcceptBookingRequestCommand(RequestId), CancellationToken.None);
Assert.True(result.IsConflict);
}
[Fact]
public async Task Accept_NotAssignedNurse_IsNotFound()
{
_currentUser.UserId.Returns(NurseUserId);
_currentUser.Roles.Returns([RoleNames.Nurse]);
_requests.GetTrackedForNurseAsync(RequestId, NurseId, Arg.Any<CancellationToken>()).Returns((BookingRequest)null);
var handler = new AcceptBookingRequestCommandHandler(_currentUser, _unitOfWork, _config, _clock, _notifications);
var result = await handler.Handle(new AcceptBookingRequestCommand(RequestId), CancellationToken.None);
Assert.True(result.IsNotFound);
}
// ---- Reject ----
[Fact]
public async Task Reject_Pending_StoresReasonAndNotifies()
{
_currentUser.UserId.Returns(NurseUserId);
_currentUser.Roles.Returns([RoleNames.Nurse]);
var request = PendingRequest(Now.AddHours(2));
_requests.GetTrackedForNurseAsync(RequestId, NurseId, Arg.Any<CancellationToken>()).Returns(request);
var handler = new RejectBookingRequestCommandHandler(_currentUser, _unitOfWork, _notifications);
var result = await handler.Handle(new RejectBookingRequestCommand("Fully booked", RequestId), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(BookingRequestStatus.RejectedByNurse, request.Status);
Assert.Equal("Fully booked", request.NurseRejectionReason);
await _notifications.Received(1).DispatchAsync(
Arg.Is<Notification>(n => n.Type == "booking_request_rejected"), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Reject_NonPending_IsConflict()
{
_currentUser.UserId.Returns(NurseUserId);
_currentUser.Roles.Returns([RoleNames.Nurse]);
var request = PendingRequest(Now.AddHours(2));
request.Accept(Now.AddMinutes(30).UtcDateTime);
_requests.GetTrackedForNurseAsync(RequestId, NurseId, Arg.Any<CancellationToken>()).Returns(request);
var handler = new RejectBookingRequestCommandHandler(_currentUser, _unitOfWork, _notifications);
var result = await handler.Handle(new RejectBookingRequestCommand("late", RequestId), CancellationToken.None);
Assert.True(result.IsConflict);
}
// ---- Cancel ----
[Fact]
public async Task Cancel_FromAcceptedAwaitingPayment_Succeeds()
{
_currentUser.UserId.Returns(CustomerUserId);
_currentUser.Roles.Returns([RoleNames.Customer]);
var request = PendingRequest(Now.AddHours(2));
request.Accept(Now.AddMinutes(30).UtcDateTime);
_requests.GetTrackedForCustomerAsync(RequestId, CustomerId, Arg.Any<CancellationToken>()).Returns(request);
_requests.GetDetailAsync(Arg.Any<long>(), Arg.Any<CancellationToken>())
.Returns(DetailStub(BookingRequestStatus.CancelledByCustomer));
var handler = new CancelBookingRequestCommandHandler(_currentUser, _unitOfWork);
var result = await handler.Handle(new CancelBookingRequestCommand(RequestId), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(BookingRequestStatus.CancelledByCustomer, request.Status);
}
[Fact]
public async Task Cancel_FromTerminal_IsConflict()
{
_currentUser.UserId.Returns(CustomerUserId);
_currentUser.Roles.Returns([RoleNames.Customer]);
var request = PendingRequest(Now.AddHours(2));
request.Reject("declined");
_requests.GetTrackedForCustomerAsync(RequestId, CustomerId, Arg.Any<CancellationToken>()).Returns(request);
var handler = new CancelBookingRequestCommandHandler(_currentUser, _unitOfWork);
var result = await handler.Handle(new CancelBookingRequestCommand(RequestId), CancellationToken.None);
Assert.True(result.IsConflict);
await _unitOfWork.DidNotReceive().CommitAsync();
}
private static BookingRequestDetailProjection DetailStub(string status)
=> new(
RequestId, status, CustomerId, NurseId,
"پرستار", 0m, 0, 5, "پدر", 8, "خدمت", "per_day",
6, "خانه", 1, "تهران", "Tehran", null, null, null,
"line", "postal", "recipient", "phone",
"any", new DateOnly(2026, 8, 1), new TimeOnly(9, 0), new TimeOnly(13, 0),
null, Now.AddHours(24).UtcDateTime, null, null, Now);
}