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