Files
2026-06-28 21:59:59 +03:30

481 lines
38 KiB
Markdown

# Backend Phase 8 — Booking requests & lifecycle (pre-payment intent)
> **Mission:** build the **pre-payment intent** layer of the engagement lifecycle — the money-free
> `booking_requests` table and its full state machine (request → accept → pay-window → expire/reject).
> A customer requests a specific nurse, patient, service variant, address, date, and **required caregiver
> gender**; the nurse sees only the limited unencrypted `customer_notes` (never the full clinical
> instructions) and accepts or rejects before a config-driven **response deadline**; on accept a
> config-driven **30-minute payment window** opens. **No `bookings` row and no money exist yet** — that
> conversion happens in b9/b10. This phase owns the two-stage clinical-disclosure boundary's *first*
> stage, the tenancy invariant, the same-gender filter, and the deadlines that the whole booking flow
> hinges on.
>
> **Track:** backend · **Depends on:** [backend-phase-3](backend-phase-3.md) (`customer_profiles`, `nurse_profiles`, `patients`, tenancy via `ICurrentUser`), [backend-phase-4](backend-phase-4.md) (`customer_addresses`), [backend-phase-5](backend-phase-5.md) (`nurse_service_variants` + the `IVariantSnapshotSerializer`), [backend-phase-1](backend-phase-1.md) (typed cached config accessor `IPlatformConfig`, the `IJobScheduler`/`BackgroundService` pattern, `notifications`), [backend-phase-7](backend-phase-7.md) (search & matching — the gender/match data customers discover nurses through) · **Unlocks:** bookings · sessions · care · EVV ([backend-phase-9](backend-phase-9.md)), and the booking-request UI ([frontend-phase-7-b8](../frontend/frontend-phase-7-b8.md))
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is the **booking-request** phase — the first half of the request→accept→pay→confirm engagement
model. The product deliberately splits the lifecycle into **two tables** so each keeps clean invariants:
a **money-free request phase** (`booking_requests`, this phase) and a **payment-backed booking phase**
(`bookings`, [backend-phase-9](backend-phase-9.md)). A `booking_requests` row can be rejected, time out,
or have its payment window lapse **without a booking ever existing** — merging the two would mean a swamp
of nullable money fields and tangled status logic. This phase ends in a complete, testable request
state machine; b9 picks it up at "payment captured" and converts an `accepted_awaiting_payment` request
into a confirmed `bookings` row.
Everything this phase needs already exists. A customer (with a `customer_profiles` row) has `patients`
and `customer_addresses`; a nurse (with a `nurse_profiles` row and a `gender`) has priced, bookable
`nurse_service_variants`; config exposes the deadline durations; search lets the customer find the nurse
in the first place. This phase wires those into a request a nurse can accept or reject.
**What already exists (do not rebuild) — confirmed from the prior phases:**
- **Customers, nurses, patients & tenancy** — [backend-phase-3](backend-phase-3.md) built
`customer_profiles`, `nurse_profiles` (carrying `is_verified`, `is_accepting_bookings`, and the
`average_rating`/`total_reviews` aggregates), and `patients` (the care recipient, separate from the
payer, carrying a load-bearing `gender`). **Tenancy is resolved off `ICurrentUser.UserId`
`customer_profiles`/`nurse_profiles`** — a patient belongs to exactly one `customer_id`; b3 started the
ownership scoping and **b8 enforces the full booking-request tenancy invariant** (§5). Read those
entities; do not re-model them.
- **Addresses** — [backend-phase-4](backend-phase-4.md) built `customer_addresses` (encrypted address
line + `decimal(9,6)` coordinates, `customer_id` FK → `customer_profiles`, filtered
`UNIQUE(customer_id) WHERE is_primary=1`). The request points at one via `customer_address_id`. Do not
geocode here — b4 already did; b9/EVV consumes the coordinates.
- **Service variants** — [backend-phase-5](backend-phase-5.md) built `nurse_service_variants` (the
**atomic bookable unit**: a nurse + category + chosen option-set at a `price` (IRR `BIGINT`) +
`price_unit`), and shipped **`IVariantSnapshotSerializer`** (a pure serializer for the canonical
`variant_snapshot_json`). The request FKs a `variant_id`; **a variant belongs to exactly one nurse**,
which the tenancy invariant verifies. **Do not compute any price/total here** — no money lives on a
request; the serializer/snapshot/total are b9's concern.
- **Config, the job runner & notifications** — [backend-phase-1](backend-phase-1.md) built
`platform_configs` + the typed cached accessor **`IPlatformConfig`** (`GetConfig<T>(key)`), seeded the
deadline keys **`nurse_response_deadline_hours`** and **`booking_payment_deadline_minutes`** (= **30**),
introduced the in-process **`IJobScheduler`/`BackgroundService`** interval-runner pattern (used for
`PurgeOldReadNotifications`), and shipped the real in-app **`INotificationDispatcher`** that writes a
`notifications` row. **Reuse all three** — read deadlines through `IPlatformConfig` (never hardcode),
schedule the expiry sweep through the same job pattern, and emit request/accept/expiry notifications
through `INotificationDispatcher`.
- **Search & matching** — [backend-phase-7](backend-phase-7.md) built the `nurse_search_index` and the
`INurseSearch` query (filterable by category/city/district/**gender**/price). The customer arrives at
this phase *from* a search result, having already filtered on `required_caregiver_gender` against nurse
gender; this phase **re-validates** that same-gender match server-side at request time (search is a
discovery aid, not the authority).
- **Cross-cutting plumbing** — [backend-phase-0](backend-phase-0.md): the REST surface (`BaseController`,
snake_case routing, rate limiting), the CQRS pipeline (`ISender`/`ICommand`/`IQuery`,
`ValidateCommandBehavior`, `OperationResult<T>`), `IDateTimeProvider` (use it — **never `DateTime.Now`**
— deadlines and expiry are time-sensitive and must be testable), `ICurrentUser`, the audit-field
interceptor, and `ICacheService`. **Reuse these; introduce no new seam.**
> **`bookings`, `booking_sessions`, `booking_care_instructions`, the three-amount money split, the
> conversion command, and `dispute_window_ends_at`** are owned by **[backend-phase-9](backend-phase-9.md)**
> (with payment capture from [backend-phase-10](backend-phase-10.md)). This phase **ends at
> `accepted_awaiting_payment`**; it must **not** create a booking, post a ledger entry, compute a price,
> or persist `variant_snapshot_json`/`address_snapshot_json`. **(DEFERRED → b9/b10.)** The request's
> `converted` terminal status is *set by b9* when it consumes the accepted request — this phase models the
> status value and the 1:1 link but does not perform the conversion.
>
> **`cancellation_policies`, `CancelBooking`, refunds, and `recurring_booking_schedules`** are also b9+
> / DEFERRED — do not build them here. A customer cancelling a *request* (before any booking) is in scope
> as the `cancelled_by_customer` transition (§3.3); a *booking* cancellation is not.
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md).
- **Product — business rules (source of truth):**
[`product/business/05-booking-and-scheduling.md`](../../../product/business/05-booking-and-scheduling.md)
— the **request → accept → pay → confirm lifecycle**, the two-table phase split (*no money on a
request*), the **response deadline** computed-and-frozen-from-config, the **30-minute payment window**,
the request statuses, and (b) the Iran-specific note that the platform **deliberately keeps the nurse's
per-request accept/reject autonomy** (availability is soft guidance, never a hard auto-accept). Read
(c) MVP vs DEFERRED so you don't pull booking/session/cancellation scope forward.
- **Product — data model (source of truth):**
[`product/data-model/05-booking-and-scheduling.md`](../../../product/data-model/05-booking-and-scheduling.md)
— the **canonical `booking_requests` schema**: the FK set + the **tenancy invariant** (patient & address
belong to `customer_id`; variant belongs to `nurse_id`), `required_caregiver_gender`
(`male`/`female`/`any`), `requested_date`/`requested_time_start`/`requested_time_end`,
**unencrypted request-stage `customer_notes`**, the exact `status` set, `nurse_response_deadline_at`
(frozen from config), `payment_deadline_at` (set on accept), and `nurse_rejection_reason`. Note the
**1:1 → `bookings` on conversion** relation (which b9 owns).
- **Type, money & gender rules on the wire:**
[`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) —
**gender is load-bearing** (`required_caregiver_gender` = `male`/`female`/`any`, matched against nurse
gender; never default or drop it silently); timestamps are **UTC ISO-8601**; enums cross the wire as
stable string codes; ids are `BIGINT`. (There is **no money** on a request — but read this so the
request's wire shape is consistent with the rest of the contract.)
- **Contract conventions:**
[`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
(envelope, snake_case routes, status codes — `400` validation/business, `403`/`404` tenancy, `409`
state-machine conflict — mandatory list pagination, locale handling).
- **Code to mirror (existing patterns):** a b3/b4/b5 feature folder under
`Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/` (request `record` + `internal sealed`
handler + `OperationResult`, validator picked up by `ValidateCommandBehavior`); an
`IEntityTypeConfiguration<T>` under `Persistence/Configuration/<Area>Config/`; a `sealed` controller
under `Baya.Web.Api/Controllers/V1/`; b1's **`IPlatformConfig.GetConfig<T>`** usage and its
**`IJobScheduler`/`BackgroundService`** retention job (mirror it for the expiry sweep); how b3 resolves
tenancy off `ICurrentUser`; how reads use `AsNoTracking()` + `.Select()` projection + pagination.
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-5.md` (the variant
shape + `IVariantSnapshotSerializer`), `.../after-backend-phase-4.md` (the `customer_addresses` shape),
`.../after-backend-phase-3.md` (profiles/patients + the tenancy + role policies), and
`.../after-backend-phase-1.md` (the config accessor keys + the job-runner pattern + notifications).
## 3. Scope — build this
A vertical slice per capability: entity + EF config + **one additive migration** → command/query
handler(s) → controller endpoint → contract. Everything is `async` with `CancellationToken` threaded
through; reads are `AsNoTracking()` + `.Select()` projection + pagination; writes go through
`IUnitOfWork` with a single `CommitAsync`. **There is no money and no `bookings` row anywhere in this
phase.**
### 3.1 Entity, config & migration
Add **`booking_requests`** as **one additive EF Core migration** on top of the existing baseline, with one
`IEntityTypeConfiguration<BookingRequest>` in `Persistence/Configuration/BookingConfig/`. The entity lives
in the Booking domain area (`Baya.Domain/Entities/Booking/`); ids are `BIGINT`.
- **`booking_requests`** — the pre-payment intent. **No money columns, ever.**
- **FKs:** `id` (BIGINT PK), `customer_id` (BIGINT FK → `customer_profiles`), `nurse_id` (BIGINT FK →
`nurse_profiles`), `patient_id` (BIGINT FK → `patients`), `variant_id` (BIGINT FK →
`nurse_service_variants`), `customer_address_id` (BIGINT FK → `customer_addresses`).
- `required_caregiver_gender` (NVARCHAR(10), **nullable**) — closed code set `male` | `female` | `any`.
Same-gender care is decisive for bodily care; this is a **first-class filter**, matched against the
nurse's `gender` at request time (§5), not a soft preference.
- `requested_date` (DATE), `requested_time_start` (TIME), `requested_time_end` (TIME) — for multi-day
engagements these are the **engagement start**; per-visit scheduling is b9's `booking_sessions`.
- `customer_notes` (NVARCHAR(1000), **nullable, UNENCRYPTED**) — the **only** clinical context the nurse
sees pre-accept (Principle 6 / two-stage disclosure, stage 1). **Do not** route this through
`IFieldEncryptor`; it is deliberately limited and plaintext. The full encrypted care instructions are
b9's `booking_care_instructions`.
- `status` (NVARCHAR(50)) — closed code set:
`pending_nurse_response``accepted_awaiting_payment``converted` / `rejected_by_nurse` /
`expired_no_response` / `payment_deadline_expired` / `cancelled_by_customer`. Guarded by a forward-only
transition check (§3.4).
- `nurse_response_deadline_at` (DATETIME2(7), **not null**) — **computed once from config at creation
and frozen** (`now + nurse_response_deadline_hours`); immune to later config changes.
- `payment_deadline_at` (DATETIME2(7), **nullable**) — null until accept; set to
`now + booking_payment_deadline_minutes` (= 30) **on accept**, then frozen.
- `nurse_rejection_reason` (NVARCHAR(500), nullable) — set on reject.
- Audit fields (`CreatedAt/ModifiedAt/CreatedById/ModifiedById`, stamped by the b0 interceptor) +
soft-delete (`deleted_at`) with the `!IsDeleted` global query filter.
- **Indexes:** `(nurse_id, status)` for the **nurse inbox** ordered list; `(customer_id, status)` for
the **customer inbox**; `(status, nurse_response_deadline_at)` and
`(status, payment_deadline_at)` so the **expiry sweep** (§3.4) selects stale rows by a covering index
instead of scanning. Do **not** add a `variant_snapshot_json` / `address_snapshot_json` column here —
those land on `bookings` in b9.
> **Do not add `bookings`, `booking_sessions`, `booking_care_instructions`, `visit_verifications`, or
> `cancellation_policies` in this migration.** They are b9's. This migration adds exactly one table.
### 3.2 Customer-side — create & cancel a request
Feature folder `Baya.Application/Features/Booking/` (Commands/Queries sub-folders per the surrounding
convention). **Customer-owner-only** for these writes; tenancy resolved via `ICurrentUser.UserId`
`customer_profiles`.
- **`CreateBookingRequestCommand`** (`Commands/CreateBookingRequest/`) — the customer requests a nurse.
Input: `nurse_id`, `variant_id`, `patient_id`, `customer_address_id`, `requested_date`,
`requested_time_start`, `requested_time_end`, `required_caregiver_gender`, optional `customer_notes`.
The handler, in one transaction:
1. **Resolve the caller's `customer_id`** from `ICurrentUser``customer_profiles` (never trust a
`customer_id` from the body).
2. **Tenancy invariant (§5):** load the `patients` row and assert `patient.customer_id == customer_id`;
load the `customer_addresses` row and assert `address.customer_id == customer_id`; load the
`nurse_service_variants` row and assert `variant.nurse_id == nurse_id`. Any mismatch ⇒ a clean
`NotFoundResult`/`FailureResult` (never a raw 500, never leaking another customer's data).
3. **Bookability checks:** the variant is active (`is_active`), the nurse `is_verified` and
`is_accepting_bookings`. A request against an inactive variant or an unbookable nurse fails cleanly.
4. **Same-gender match (§5):** if `required_caregiver_gender` is `male`/`female`, assert the nurse's
`gender` equals it; `any` matches either. A mismatch is a validation failure naming the conflict.
5. **Compute & freeze `nurse_response_deadline_at`** = `IDateTimeProvider.UtcNow +
IPlatformConfig.GetConfig<int>("nurse_response_deadline_hours")`. **Read from config — never
hardcode.** Store the absolute timestamp on the row so a later config change cannot move it.
6. Insert the `booking_requests` row with `status = pending_nurse_response`, `payment_deadline_at =
null`; `CommitAsync` once.
7. **Notify the nurse** of the new request via `INotificationDispatcher` (a `booking_request_received`
notification type, `data_json` carrying the request id + patient display name + requested date — no
PII beyond what stage-1 disclosure allows).
- FluentValidation: all FKs present/positive; `requested_time_end > requested_time_start`;
`requested_date` not in the past; `required_caregiver_gender` in the closed set when supplied;
`customer_notes` ≤ 1000 chars.
- **`CancelBookingRequestCommand`** (`Commands/CancelBookingRequest/`) — the customer withdraws a request
that is still `pending_nurse_response` **or** `accepted_awaiting_payment` (before they pay).
Owner-tenancy; transition to `cancelled_by_customer` through the guard (§3.4). A request already
`converted`/`rejected_by_nurse`/`expired_*` cannot be cancelled ⇒ `409` conflict. (This is a *request*
cancellation only — a *booking* cancellation with refund tiers is b9+/DEFERRED.)
### 3.3 Nurse-side — accept & reject
**Nurse-owner-only**; tenancy resolved via `ICurrentUser.UserId` → `nurse_profiles`; the request's
`nurse_id` must equal the caller's nurse id (else `403`/`NotFound`).
- **`AcceptBookingRequestCommand`** (`Commands/AcceptBookingRequest/`) — the assigned nurse accepts a
`pending_nurse_response` request. The handler:
1. Load the request scoped to the caller's `nurse_id`; assert `status == pending_nurse_response` (else
`409`). Assert `nurse_response_deadline_at` has **not** already passed (`IDateTimeProvider.UtcNow`);
an accept after the deadline is rejected with a clear message (the sweep may not have run yet — the
command must self-guard, not rely on the job).
2. **Set & freeze `payment_deadline_at`** = `IDateTimeProvider.UtcNow +
IPlatformConfig.GetConfig<int>("booking_payment_deadline_minutes")` (= 30). **From config, never
hardcode 30.**
3. Transition `status → accepted_awaiting_payment` through the guard; `CommitAsync`.
4. **Notify the customer** (`booking_request_accepted`, `data_json` with the request id + the
`payment_deadline_at` so the client can show the 30-minute countdown).
- **Two-stage disclosure (§5):** the accept handler — and the nurse inbox/detail queries — expose
**only `customer_notes`**, never any encrypted care instructions (those don't exist until b9 and are
gated to post-confirmation). There is nothing encrypted to leak here *because there is nothing
encrypted on a request* — but the contract and queries must make the stage-1-only boundary explicit so
b9 inherits it correctly.
- **`RejectBookingRequestCommand`** (`Commands/RejectBookingRequest/`) — the assigned nurse declines a
`pending_nurse_response` request with a required `nurse_rejection_reason`. Transition →
`rejected_by_nurse`; notify the customer (`booking_request_rejected`). Reject is only valid from
`pending_nurse_response` (else `409`). FluentValidation: reason non-empty, ≤ 500 chars.
### 3.4 Status guard & the expiry sweep (this phase owns the scheduled job)
- **Forward-only transition guard.** Add an allowed-transition table/helper for `booking_requests.status`
and route **every** write through it; an illegal transition returns a `409` `OperationResult` conflict,
never a silent overwrite. Allowed edges:
- `pending_nurse_response` → `accepted_awaiting_payment` | `rejected_by_nurse` | `expired_no_response`
| `cancelled_by_customer`
- `accepted_awaiting_payment` → `converted` (b9 only) | `payment_deadline_expired` |
`cancelled_by_customer`
- all of `converted` / `rejected_by_nurse` / `expired_no_response` / `payment_deadline_expired` /
`cancelled_by_customer` are **terminal** (no outgoing edges).
- **`ExpireBookingRequestsCommand`** (`Commands/ExpireBookingRequests/`) + a hosted **`BackgroundService`**
registered through the b1 `IJobScheduler` pattern, running on a short interval (e.g. every minute —
config-driven if b1 exposes an interval key, else a sensible constant documented in the report). On each
tick, in a single bounded, paginated pass (do **not** load the whole table):
- select `pending_nurse_response` rows where `nurse_response_deadline_at <= UtcNow` → transition →
`expired_no_response`; notify the customer (`booking_request_expired_no_response`).
- select `accepted_awaiting_payment` rows where `payment_deadline_at <= UtcNow` → transition →
`payment_deadline_expired`; notify the customer (`booking_request_payment_window_expired`).
- The sweep uses the covering indexes from §3.1, batches its updates, threads `CancellationToken`, and is
**idempotent/re-entrant** (a row already moved by a concurrent accept/reject is simply skipped — the
`status` predicate in the `WHERE` is the guard; never assume a row is still pending just because the
previous tick saw it). Expiry is computed against `IDateTimeProvider.UtcNow` so tests can drive it
deterministically.
- The command is also exposed as an **admin/manual trigger** endpoint (§3.6) so a human/test can fire the
sweep on demand without waiting for the interval.
### 3.5 Queries — customer inbox, nurse inbox, single request
- **`ListBookingRequestsQuery`** (`Queries/ListBookingRequests/`) — paginated, **role-aware**: a customer
sees their own requests (scoped to `customer_id`); a nurse sees requests addressed to them (scoped to
`nurse_id`). Optional `status` filter. `AsNoTracking()` + `.Select()` projection returning: request id,
status, the **counterparty** (nurse display name + rating for the customer view; patient display name +
requested date for the nurse view), `requested_date`/times, `required_caregiver_gender`,
`nurse_response_deadline_at`, `payment_deadline_at`, and — for the **nurse view** —
`customer_notes` (stage-1 only). Order: actionable first (e.g. `pending_nurse_response` /
`accepted_awaiting_payment` before terminal states), then by deadline. **Never** project encrypted/care
fields (there are none, and there must never be any added to this query).
- **`GetBookingRequestQuery`** (`Queries/GetBookingRequest/`) — a single request, visible to its
**customer or its nurse** (admin too). Returns the full request projection: the resolved variant label
(via the b5 variant projection — read the canonical offering, do not duplicate price math), patient +
address summary (the **customer** sees their own full address; the **nurse** sees only what stage-1
disclosure allows — a coarse location/the request fields, **not** the encrypted full address line),
both deadlines, status, and `customer_notes`. A caller who is neither the request's customer nor its
nurse ⇒ `403`/`NotFound`.
### 3.6 REST endpoints
Controllers under `Baya.Web.Api/Controllers/V1/` (`sealed`, `BaseController`, inject `ISender`,
`[controller]`/`[action]` snake_case tokens, `base.OperationResult(...)`, narrowest authorize policy,
lists paginated). Routes shown logically; the snake_case transformer produces the real segments.
| Verb & route | Maps to | Auth |
| --- | --- | --- |
| `POST /v1/booking_requests` | `CreateBookingRequestCommand` | customer (owner) |
| `POST /v1/booking_requests/{id}/cancel` | `CancelBookingRequestCommand` | customer (owner) |
| `POST /v1/booking_requests/{id}/accept` | `AcceptBookingRequestCommand` | nurse (assigned) |
| `POST /v1/booking_requests/{id}/reject` | `RejectBookingRequestCommand` | nurse (assigned) |
| `GET /v1/booking_requests` | `ListBookingRequestsQuery` | customer or nurse (role-scoped) |
| `GET /v1/booking_requests/{id}` | `GetBookingRequestQuery` | customer / nurse (party) / admin |
| `POST /v1/admin/booking_requests/expire` | `ExpireBookingRequestsCommand` (manual trigger) | admin |
### 3.7 Out of scope (DEFERRED — do not build)
- **`bookings`, the three-amount money split (`gross_price_irr`/`balinyaar_commission_irr`/
`nurse_payout_amount`), `psp_fee_amount`, the conversion command, `booking_sessions`,
`booking_care_instructions`, `visit_verifications`, `dispute_window_ends_at`,
`variant_snapshot_json`/`address_snapshot_json`** — **(DEFERRED → [backend-phase-9](backend-phase-9.md)
+ [backend-phase-10](backend-phase-10.md)).** This phase stops at `accepted_awaiting_payment`; b9
consumes the accepted request, captures payment (b10), and sets the request to `converted`.
- **`cancellation_policies`, `CancelBooking`/`CancelSession`, refund tiers, `recurring_booking_schedules`,
`nurse_availability_slots`/`_exceptions` hard-blocking** — **(DEFERRED)**; availability stays *soft
guidance* (search-only). The nurse always individually accepts/rejects — never auto-accept from
availability.
- **SMS/push notification channels** — `INotificationDispatcher` writes **in-app only** (the channel
abstraction exists from b0/b1; SMS/push are DEFERRED). Emit the in-app notification; do not add a real
SMS path.
## 4. Mocks & seams in this phase
**None introduced.** This phase owns no third-party integration — booking-request data is fully Balinyaar's.
It **reuses** existing seams; it must **not** redefine them:
| Reused seam | From | Used for |
| --- | --- | --- |
| `IPlatformConfig` | [b1](backend-phase-1.md) | reading `nurse_response_deadline_hours` & `booking_payment_deadline_minutes` (cached) |
| `INotificationDispatcher` | [b1](backend-phase-1.md) (real in-app) | request-received / accepted / rejected / expired in-app notifications |
| `IJobScheduler` / `BackgroundService` | [b1](backend-phase-1.md) | the recurring expiry sweep |
| `IDateTimeProvider` | [b0](backend-phase-0.md) | deadline computation + expiry, testably |
| `ICurrentUser` | [b0](backend-phase-0.md) | tenancy resolution (customer/nurse) |
Because this phase **mocks nothing**, there is **no `mocks-registry.md` row to add** — state that
explicitly in your report (§8) so the next agent doesn't go looking. The expiry job is an *internal*
hosted service, not an external seam; it does not go in the mock registry.
## 5. Critical rules you must not get wrong
- **NO money exists on a `booking_requests` row — and no `bookings` row exists yet.** The request phase is
deliberately money-free; a `bookings` row exists **only** when the nurse accepted **AND** payment was
captured (b9/b10). Never add a money column to `booking_requests`, never compute a price/total here,
never create a booking on accept alone. Accept only opens the payment window.
- **Two-stage clinical disclosure (Principle 6), stage 1.** Pre-accept (and pre-payment), the nurse sees
**only the unencrypted, limited `customer_notes`** — never any full clinical/care instructions. Those
are b9's encrypted `booking_care_instructions`, readable **only post-confirmation** by the assigned
nurse + admin. Do not add an encrypted clinical field to this table; do not surface anything beyond
`customer_notes` in the nurse inbox/detail queries. This boundary is the *entire reason* the request and
booking phases are split — preserve it exactly so b9 inherits a clean seam.
- **Tenancy invariant — enforced before the request is created.** The `patient` **and** the
`customer_address` must belong to the caller's `customer_id`; the `variant` must belong to the
requested `nurse_id`. Resolve `customer_id`/`nurse_id` from `ICurrentUser`, never from the request body.
A cross-customer patient/address or a variant that isn't the nurse's is a clean failure, never a
success and never a leak of the other party's data.
- **`required_caregiver_gender` is a first-class same-gender filter, not a soft preference.** When it is
`male`/`female`, it **must** be matched against the nurse's `gender` at request time and rejected on
mismatch; `any` matches either. Same-gender bodily care is decisive in the Iranian context. Never
default it silently or treat it as advisory.
- **Deadlines come from config and are frozen on the row — never hardcoded, never recomputed.** Compute
`nurse_response_deadline_at` from `nurse_response_deadline_hours` at **create** time and
`payment_deadline_at` from `booking_payment_deadline_minutes` (= 30) at **accept** time, both via
`IPlatformConfig` + `IDateTimeProvider`, and **store the absolute timestamp**. A later config change must
**not** move an existing request's deadlines. Do not read "30 minutes" as a literal anywhere.
- **The nurse always individually accepts or rejects.** Availability slots are soft guidance only (b5-area,
DEFERRED) — never auto-accept a request from availability. This deliberate per-request autonomy also
underpins the worker-misclassification posture; do not erode it.
- **Status transitions go through the forward-only guard.** Every accept/reject/cancel/expire/convert
edge is validated; illegal transitions return `409`, never a silent overwrite. Terminal states have no
outgoing edges. The expiry sweep's `WHERE status = …` predicate is the concurrency guard — a row moved
by a racing accept is simply skipped.
- **Time is injected, expiry is idempotent.** Use `IDateTimeProvider.UtcNow` everywhere (no `DateTime.Now`);
accept/reject **self-guard** against an already-passed deadline rather than trusting that the sweep ran;
the sweep is bounded, paginated, re-entrant, and safe to run concurrently with user actions.
- **The request → booking link is 1:1 and owned by b9.** Model the `converted` status and let b9 create
the `bookings` row that points back at the request; this phase never writes that link itself.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `booking_requests` exists via **one additive migration** with the §3.1 columns, the closed `status`
set, the **unencrypted** `customer_notes`, `nurse_response_deadline_at` (not null) + nullable
`payment_deadline_at`, the FK set, the inbox/expiry indexes, soft-delete filter, and **no money
column**.
- [ ] `CreateBookingRequestCommand` enforces the **tenancy invariant** (patient + address ∈ `customer_id`,
variant ∈ `nurse_id`), the **same-gender** match, bookability (active variant, verified/accepting
nurse), and **freezes `nurse_response_deadline_at` from `nurse_response_deadline_hours`**; it
notifies the nurse.
- [ ] `AcceptBookingRequestCommand` sets `payment_deadline_at = now + booking_payment_deadline_minutes`
(**30, from config**), transitions to `accepted_awaiting_payment`, notifies the customer, and
self-guards against an expired response deadline. `RejectBookingRequestCommand` requires a reason and
transitions to `rejected_by_nurse`. `CancelBookingRequestCommand` handles `cancelled_by_customer`.
- [ ] The forward-only **status guard** rejects illegal transitions with `409`; `ExpireBookingRequestsCommand`
+ its `BackgroundService` transition `pending_nurse_response → expired_no_response` and
`accepted_awaiting_payment → payment_deadline_expired`, paginated, idempotent, time-injected, and is
also reachable via the admin manual-trigger endpoint.
- [ ] `ListBookingRequestsQuery` (role-scoped customer/nurse inbox, paginated, projected) and
`GetBookingRequestQuery` (party/admin only) are implemented; the **nurse inbox exposes only
`customer_notes`** and never any care/clinical field.
- [ ] Tests prove the §7 scenarios (incl. **cross-customer patient rejected**, **same-gender mismatch
rejected**, accept sets the 30-min window, the **expiry job transitions stale rows**); `dotnet build
Baya.sln` zero new warnings; `dotnet test Baya.sln` green including this phase's tests.
- [ ] The contract `dev/contracts/domains/booking-requests.md` is written, the `swagger.json` snapshot is
refreshed, and the `server/CLAUDE.md` *Project map* notes the new `Features/Booking` area, the
`BookingConfig` configuration folder, and the request-expiry `BackgroundService`.
## 7. How to test (what a human can verify after this phase)
Run the API (`dotnet run --project src/API/Baya.Web.Api/...`) against a reachable SQL Server; use Swagger
or curl, signing in as the relevant role. These expected results become the "what can be tested" section
of your report.
1. **Create a request (happy path).** As a customer, `POST /v1/booking_requests` with your own
`patient_id` + `customer_address_id`, a nurse's `variant_id` (that nurse `is_verified` &
`is_accepting_bookings`), a future `requested_date`, `required_caregiver_gender: any`, and a short
`customer_notes` → `200`, status `pending_nurse_response`, `nurse_response_deadline_at` set to
`now + nurse_response_deadline_hours`, `payment_deadline_at` null. The nurse receives an in-app
notification.
2. **Cross-customer patient is rejected.** Repeat the create but with a `patient_id` belonging to a
**different** customer → a clean tenancy failure (`403`/`404` `OperationResult`), **not** a 500 and
**no** request row created. Same for an `customer_address_id` you don't own and a `variant_id` that
isn't the requested nurse's.
3. **Same-gender mismatch is rejected.** Request `required_caregiver_gender: female` against a `male`
nurse → a validation failure naming the gender conflict; `any` against any nurse succeeds.
4. **Nurse accepts → 30-minute window.** As the assigned nurse, `POST /v1/booking_requests/{id}/accept` →
`200`, status `accepted_awaiting_payment`, `payment_deadline_at = now + booking_payment_deadline_minutes`
(30 min). The customer gets a `booking_request_accepted` notification carrying the deadline. No
`bookings` row exists (there is no bookings table yet).
5. **Nurse rejects.** `POST /v1/booking_requests/{id}/reject` with a reason on a fresh pending request →
`200`, status `rejected_by_nurse`, `nurse_rejection_reason` stored, customer notified. Rejecting a
non-pending request → `409`.
6. **Nurse inbox shows only `customer_notes`.** As the nurse, `GET /v1/booking_requests?status=pending_nurse_response`
→ the request appears with `customer_notes` and the per-request countdown to `nurse_response_deadline_at`,
and **no** encrypted/clinical field of any kind.
7. **Customer cancels before paying.** On an `accepted_awaiting_payment` request, the customer
`POST /v1/booking_requests/{id}/cancel` → `200`, status `cancelled_by_customer`. Cancelling a
`converted`/`rejected`/`expired` request → `409`.
8. **The expiry job transitions stale requests.** Create a request, then (using the injected clock in a
test, or by waiting / a short test config) advance past the response deadline and
`POST /v1/admin/booking_requests/expire` (admin) → the pending request becomes `expired_no_response`;
an accepted request past `payment_deadline_at` becomes `payment_deadline_expired`. Both notify the
customer. Running the trigger again is a no-op (idempotent).
9. **Tenancy on read.** As a third party (neither the request's customer nor nurse),
`GET /v1/booking_requests/{id}` → `403`/`NotFound`, never the request's data.
## 8. Hand off & document (close the phase)
- **Docs to update (same change):** `server/CLAUDE.md` *Project map* — add the `Features/Booking`
(booking-requests) feature area, the new `booking_requests` table + the
`Persistence/Configuration/BookingConfig/` folder, and the request-expiry `BackgroundService` (note it
reuses the b1 `IJobScheduler` pattern). If you established the forward-only status-guard helper as a
reusable pattern (b9 will reuse it for the `bookings` machine), note it in `server/CONVENTIONS.md`. If
you discovered/decided any business rule not already in the product docs, reflect it in
[`product/business/05-booking-and-scheduling.md`](../../../product/business/05-booking-and-scheduling.md)
or [`product/data-model/05-booking-and-scheduling.md`](../../../product/data-model/05-booking-and-scheduling.md)
(no invented rules — record decisions, and regenerate the HTML view per `product/CLAUDE.md` if you
touched Markdown).
- **Contract to write:** publish **`dev/contracts/domains/booking-requests.md`** — the §3.6 routes;
request/response shapes for create/accept/reject/cancel and the two queries; the `status` enum
(`pending_nurse_response`/`accepted_awaiting_payment`/`converted`/`rejected_by_nurse`/
`expired_no_response`/`payment_deadline_expired`/`cancelled_by_customer`) and `required_caregiver_gender`
enum (`male`/`female`/`any`); the deadline timestamps (UTC ISO-8601); the **tenancy** and
**stage-1-disclosure** notes (nurse view exposes only `customer_notes`); the failure cases and status
codes (`400` validation/same-gender, `403`/`404` tenancy, `409` illegal transition / already-expired) —
per [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
and [`money-and-types.md`](../../contracts/conventions/money-and-types.md) (gender section), starting
from the [domain contract template](../../contracts/domains/_TEMPLATE.md). Refresh the `swagger.json`
snapshot per [`../../contracts/openapi/README.md`](../../contracts/openapi/README.md) so
[frontend-phase-7-b8](../frontend/frontend-phase-7-b8.md) can derive its types (it does not guess
shapes).
- **Handoff & report:** write `shared-working-context/backend/handoff/after-backend-phase-8.md` — the
request lifecycle is live end-to-end (create/accept/reject/cancel/expire); the deadlines are config-
driven and frozen; **what b9 must consume** (an `accepted_awaiting_payment` request → create the
`bookings` row, set the request to `converted` through the guard, and that the **full encrypted care
instructions are b9's stage-2** — this phase intentionally exposes only `customer_notes`); **what
f7-b8 can now build** (the request form C4, the awaiting-acceptance/status tracker C5 with the response
+ payment countdowns, and the nurse incoming-requests inbox with accept/reject). Append your phase
summary to `shared-working-context/backend/STATUS.md`, and write `reports/backend-phase-8-report.md`
(what was built, **what is now testable and exactly how** — the §7 steps, that **nothing is mocked**
here, the contract produced, and follow-ups for b9: conversion, sessions, two-stage stage-2 care
instructions). State explicitly in the report that this phase adds **no** `mocks-registry.md` row.
- **Memory:** save a `project`-type memory note for the non-obvious decisions here — the **two-table phase
split / no-money-on-a-request** rule, the **stage-1 (`customer_notes`-only) vs stage-2 (encrypted
care-instructions) disclosure boundary**, the **deadlines-frozen-from-config** rule (which config keys),
the **tenancy invariant** (patient+address ∈ customer, variant ∈ nurse), the **same-gender match at
request time**, and the **forward-only status guard + idempotent expiry sweep** — with a one-line
`MEMORY.md` pointer.