# 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(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`), `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//{Commands|Queries}//` (request `record` + `internal sealed` handler + `OperationResult`, validator picked up by `ValidateCommandBehavior`); an `IEntityTypeConfiguration` under `Persistence/Configuration/Config/`; a `sealed` controller under `Baya.Web.Api/Controllers/V1/`; b1's **`IPlatformConfig.GetConfig`** 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` 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("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("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.