backend phase 8
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
# Backend phase 8 report — Booking requests (pre-payment intent)
|
||||
|
||||
**Date:** 2026-07-06 · **Track:** backend · **Depends on:** b1 (config/jobs/notifications), b3 (profiles/
|
||||
patients/tenancy), b4 (addresses), b5 (variants), b7 (search/gender). **Unlocks:** b9 (bookings/sessions/care)
|
||||
and frontend f7-b8.
|
||||
|
||||
## What was built
|
||||
|
||||
The **money-free** first half of the engagement lifecycle — the `booking_requests` table and its full state
|
||||
machine (request → accept → pay-window → expire/reject/cancel). **One additive migration** adds a new
|
||||
**`booking`** schema with a single table `BookingRequests`. **No money, no `bookings` row, no snapshot, no
|
||||
price** anywhere in this phase.
|
||||
|
||||
- **Domain** (`Baya.Domain/Entities/Booking/`): `BookingRequest` (guarded `status` with a private setter, both
|
||||
deadlines as UTC `DateTime`, unencrypted `CustomerNotes`), `BookingRequestStatus` (7 const codes),
|
||||
`BookingRequestTransitions` (forward-only edge table + `CanTransition`), `CaregiverGender` (`male`/`female`/
|
||||
`any` + `Matches`).
|
||||
- **Application** (`Features/Booking/`): commands `CreateBookingRequest`, `AcceptBookingRequest`,
|
||||
`RejectBookingRequest`, `CancelBookingRequest`, `ExpireBookingRequests`; queries `ListBookingRequests`
|
||||
(role-scoped) + `GetBookingRequest` (party/admin); `BookingRequestMapper` (stage-1 address masking);
|
||||
DTOs in `Models/Booking/`; `NurseBookingContext` in `Models/Identity/`.
|
||||
- **Infrastructure**: `BookingRequestConfig` (EF config + the 4 covering indexes + soft-delete filter),
|
||||
`BookingRequestRepository` on `IUnitOfWork`, `INurseProfileRepository.GetBookingContextByIdAsync`, and
|
||||
`BookingRequestExpiryHostedService` (recurring sweep, reuses the b1 `IJobScheduler`/`BackgroundService` seam),
|
||||
registered in `AddPersistenceServices`.
|
||||
- **API** (`Controllers/V1/`): `BookingRequestsController` (`create`/`accept/{id}`/`reject/{id}`/`cancel/{id}`/
|
||||
`list`/`get/{id}`, `[Authorize]`, role/ownership enforced in handlers) + `AdminBookingRequestsController`
|
||||
(`expire`, `DynamicPermission`).
|
||||
|
||||
## The critical rules, as enforced
|
||||
|
||||
- **No money / no booking.** A request has no price column; accept only sets `payment_deadline_at`. Conversion
|
||||
to a `bookings` row is b9 (`MarkConverted()` exists but b8 never calls it).
|
||||
- **Two-stage disclosure (stage 1).** `customer_notes` is unencrypted and the only clinical text the nurse
|
||||
sees; the nurse view/inbox mask the encrypted full address to a coarse city/district.
|
||||
- **Tenancy invariant** resolved from `ICurrentUser` (never the body): patient + address ∈ the caller's
|
||||
customer, variant ∈ the requested nurse — a mismatch is a clean 404.
|
||||
- **Same-gender match** at request time against `User.Gender`; required, never defaulted.
|
||||
- **Deadlines frozen from config** (`nurse_response_deadline_hours` at create, `booking_payment_deadline_minutes`
|
||||
= 30 at accept) as absolute UTC timestamps; a later config change can't move them.
|
||||
- **Forward-only guard**: every write pre-checks `CanTransition` → 409 on an illegal edge; terminal states have
|
||||
no outgoing edge. Accept self-guards against a passed response deadline. The expiry sweep is bounded,
|
||||
paginated, time-injected, and idempotent (the `WHERE status = …` predicate is the concurrency guard).
|
||||
|
||||
## What is now testable, and exactly how
|
||||
|
||||
Run the API against a reachable SQL Server (`dotnet run --project src/API/Baya.Web.Api/...`), sign in per role.
|
||||
The §7 scenarios all pass:
|
||||
|
||||
1. **Create (happy path)** — customer `POST /v1/booking_requests/create` with own patient/address, a
|
||||
verified+accepting nurse's active variant, a future date, `requiredCaregiverGender: any` → `200`,
|
||||
`pending_nurse_response`, `nurseResponseDeadlineAt = now + nurse_response_deadline_hours`, `paymentDeadlineAt`
|
||||
null; the nurse gets a `booking_request_received` notification.
|
||||
2. **Cross-customer patient/address/variant** → clean `404`, no row created.
|
||||
3. **Same-gender mismatch** (`female` vs a `male` nurse) → `400`; `any` succeeds.
|
||||
4. **Accept** → `200`, `accepted_awaiting_payment`, `paymentDeadlineAt = now + 30 min`, customer notified; no
|
||||
bookings row.
|
||||
5. **Reject** with reason → `200`, `rejected_by_nurse`; rejecting a non-pending request → `409`.
|
||||
6. **Nurse inbox** (`list?role=nurse`) shows `customerNotes` + the response countdown, no clinical/encrypted field.
|
||||
7. **Customer cancels** an accepted request → `200`, `cancelled_by_customer`; cancelling a terminal one → `409`.
|
||||
8. **Expiry** — admin `POST /v1/admin_booking_requests/expire` (or the recurring job) moves stale pending →
|
||||
`expired_no_response` and stale accepted → `payment_deadline_expired`, notifies the customer; re-running is a
|
||||
no-op.
|
||||
9. **Read tenancy** — a third party `GET /v1/booking_requests/get/{id}` → `404`.
|
||||
|
||||
**Automated tests (215 total pass):** 14 handler-unit (`CreateBookingRequestHandlerTests`,
|
||||
`RespondBookingRequestHandlerTests`) + the transition-machine tests (`BookingRequestTransitionsTests`) + 4
|
||||
DB-backed SQLite tests over the real EF model (`BookingRequestExpiryTests`, `BookingRequestQueryTests` via
|
||||
`BookingTestHost`) + 5 API integration tests (`BookingRequestsApiTests`: 401/400/empty-inbox/admin-expire).
|
||||
`dotnet build Baya.sln` = 0 new warnings; `dotnet test Baya.sln` green. Migration applied to the dev DB and the
|
||||
sweep verified running against SQL Server on boot.
|
||||
|
||||
## Contracts produced / consumed
|
||||
|
||||
- **Produced:** `dev/contracts/domains/booking-requests.md`; `swagger.v1.json` refreshed (the 7 booking paths +
|
||||
the 3 DTOs).
|
||||
- **Consumed:** b1 config keys (`nurse_response_deadline_hours`, `booking_payment_deadline_minutes`), b3
|
||||
profiles/patients + tenancy, b4 addresses, b5 variants (bookable unit; `GetOwnedAsync` tenancy), b7 nurse
|
||||
gender/matching data.
|
||||
|
||||
## Nothing is mocked here
|
||||
|
||||
This phase owns **no** third-party integration and introduces **no** DI seam. It reuses `IPlatformConfig`,
|
||||
`INotificationDispatcher`, `IJobScheduler`/`BackgroundService`, `IDateTimeProvider`, `ICurrentUser`. There is
|
||||
**no new `mocks-registry.md` row** — the existing `IJobScheduler` row was updated to note the second hosted job
|
||||
(the internal expiry sweep is not an external seam).
|
||||
|
||||
## Follow-ups for b9 (+ b10)
|
||||
|
||||
- Consume an `accepted_awaiting_payment` request → create the `bookings` row on payment capture (b10), then
|
||||
`request.MarkConverted()` through the guard. The request↔booking link is 1:1 and b9-owned.
|
||||
- Stage 2: the encrypted `booking_care_instructions` (post-confirmation, assigned nurse + admin only). Do not
|
||||
add a clinical field to `booking_requests`.
|
||||
- The three-amount money split, `variant_snapshot_json`/`address_snapshot_json`, `booking_sessions`, EVV, and
|
||||
`dispute_window_ends_at` are all b9/b10.
|
||||
- Reuse the forward-only status-machine pattern (CONVENTIONS §6) for the `bookings` state machine.
|
||||
Reference in New Issue
Block a user