# Flow — booking-request

> Last verified: 2026-08-02 against commit `c841bde`

**Actor(s):** customer (C4/C5) + nurse (inbox) · **Status:** partial
**Client:** real · **Server:** real
**Business source:** [product/business/05-booking-and-scheduling.md](../../product/business/05-booking-and-scheduling.md)
**Integration:** [docs/integration/domains/booking-requests.md](../integration/domains/booking-requests.md)

## What it does

Stage one of booking, and the only stage with **no money in it**. A family picks a nurse from search, names
the patient, the service variant, the address, a date/time window and the caregiver gender they need, and
sends a request. The nurse has a frozen response window to accept or decline; an accept opens a 30-minute
payment window and hands the customer to [checkout](checkout-and-payment.md). A `booking_requests` row
becomes a `bookings` row only on payment capture.

## Screens

| Step | Route | Component / notes |
| --- | --- | --- |
| C4 — request form | `/fa/bookings/request?nurse_id=&variant_id=&required_gender=` | `client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx` (727 ln). Waits for patients + addresses + nurse profile before mounting so `defaultValues` are the only values. `JalaliDateIntentPicker` + three preset time windows (`TIME_WINDOWS`, :55) or custom. Blocks a same-gender mismatch before the round-trip: `genderMismatch` at :216, submit guard :248, inline alert :544 |
| C5 — awaiting response | `/fa/bookings/request/[id]` | `…/bookings/request/[id]/page.tsx` (368 ln). Polls every 15 s while non-terminal, stops on terminal (`useBookingRequest`, hook :20-23). `StepperHeader` 3-step tracker, `BookingRequestSummaryCard`, `CountdownTimer`. Each of the five terminal statuses gets its own recovery card |
| customer inbox | `/fa/bookings` | `…/(customer)/bookings/BookingsScreen.tsx:53` mounts `useCustomerRequests()` and deep-links rows back to C5. There is **no dedicated customer requests page** — this list is the only entry point back into C5 |
| nurse inbox | `/fa/nurse/requests` | `…/nurse/requests/page.tsx` (198 ln). Three tabs «در انتظار» / «پاسخ‌داده» / «منقضی»; urgency-tinted countdown pill (teal >2 h, amber <2 h, terracotta <30 min, :17-18); shows `customerNotes` only — never an address |
| nurse detail | `/fa/nurse/requests/[id]` | `…/nurse/requests/[id]/page.tsx` (343 ln). Accept (confirm dialog) / reject (reason dialog, ≤500 chars). A stale action's `409` becomes the «action_stale» toast + refetch (:82-89) |

Client service seam is **real**: [`USE_BOOKING_REQUESTS_MOCK = false`](../../client/src/services/bookingRequests/constants.ts) (`constants.ts:14`).
Its doc-comment above that line still argues for the mock and is stale.

## API

The six client-facing ops are wired and were traced client → server; `checkout_summary` sits on this
controller but is consumed by the checkout flow, and `expire` has no client caller at all. Shapes live in
[docs/integration/domains/booking-requests.md](../integration/domains/booking-requests.md).

| Call | Endpoint | Notes |
| --- | --- | --- |
| C4 submit | `POST /booking_requests/create` | `clientApi.ts:27` → `BookingRequestsController.cs:34` → `CreateBookingRequestCommand.Handler.cs` |
| C5 / nurse detail | `GET /booking_requests/get/{id}` | `clientApi.ts:35` → controller `:59`. Role-scoped masking in the mapper, not the client |
| both inboxes | `GET /booking_requests/list?role=&status=&page=&pageSize=` | `clientApi.ts:37` → controller `:54`. Role resolution `ListBookingRequestsQuery.Handler.cs:45-58` |
| nurse accept | `POST /booking_requests/accept/{id}` | `clientApi.ts:48` → controller `:44` |
| nurse reject | `POST /booking_requests/reject/{id}` | `clientApi.ts:51` → controller `:49`; id comes from the **route**, reason from the body |
| customer cancel | `POST /booking_requests/cancel/{id}` | `clientApi.ts:59` → controller `:39` |
| checkout money | `GET /booking_requests/checkout_summary/{id}` | on this controller, consumed by [checkout-and-payment](checkout-and-payment.md) |
| expiry sweep | `POST /admin_booking_requests/expire` | `AdminBookingRequestsController.cs:22` is `[Authorize(DynamicPermission)]` — **403 for both demo admins** (re-checked live: …020 and …030 both `403 "Authorization Error"`). The in-process `booking_request_expiry` job (`BookingRequestExpiryJob.cs`, 1-min interval) runs it unattended anyway |

## Rules that must hold

| Rule | Where enforced | Live evidence |
| --- | --- | --- |
| Response deadline frozen from config, never recomputed ([05-booking §a.2](../../product/business/05-booking-and-scheduling.md)) | `CreateBookingRequestCommand.Handler.cs:72,86` — `nurse_response_deadline_hours` | created `2026-08-02T12:56:14` → deadline `2026-08-03T12:56:14` = **24 h** |
| 30-minute payment window on accept ([05-booking §a.3](../../product/business/05-booking-and-scheduling.md)) | `AcceptBookingRequestCommand.Handler.cs:49-50` — `booking_payment_deadline_minutes` | accepted `12:56:42` → `paymentDeadlineAt 13:26:42` = **30 min** |
| Forward-only status; a sideways move is a clean `409`, never a 500 | `AcceptBookingRequestCommand.Handler.cs:38-39`; `BookingRequestTransitions` behind `BookingRequest.cs:66` | second accept on req 21 → `409 "This request can no longer be accepted."`; second reject on req 23 → `409 "This request can no longer be rejected."` |
| Stage-1 disclosure: nurse gets `customerNotes` + coarse city/district only | `BookingRequestMapper.cs:35-38` (`includeFullAddress`) | nurse view of req 21: `addressLine`/`postalCode`/`recipientName`/`recipientPhone` all `null`; `cityNameFa`/`districtNameFa` present. Customer's own view: full address |
| Tenancy — patient + address ∈ caller, variant ∈ nurse; a mismatch is `404` not a leak | `CreateBookingRequestCommand.Handler.cs:37-47`; `GetTrackedForNurseAsync` | nurse 2 reading nurse 1's request 21 → **404** |
| Same-gender care is decisive, never defaulted | client `page.tsx:216,248`, server `CreateBookingRequestCommand.Handler.cs:61-64` (`CaregiverGender.Matches`) | — |
| Nurse must be verified **and** accepting bookings | `CreateBookingRequestCommand.Handler.cs:56-57` | — |
| `customerNotes` ≤ 1000, `nurseRejectionReason` ≤ 500 | `CreateBookingRequestCommand.Validator.cs:25`, `RejectBookingRequestCommand.Validator.cs:10-12`, mirrored client-side in `constants.ts:31,34` | — |
| Countdown renders a **server-frozen** deadline, never a locally computed one | `CountdownTimer.tsx:84-85` | true, but see the timezone gap below |

## How to test

Log in as **09120000010** (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md) for boot, the
`Seams__Sms__Provider=mock` override, and `dev/last_otp`.

**The seeded world cannot exercise accept/reject.** It was seeded 2026-07-26; every *seeded* request has aged
into a terminal state. The seeded baseline is 11 rows for nurse 1 (`converted` 6, `payment_deadline_expired` 3,
`rejected_by_nurse` 1, `expired_no_response` 1) and 9 for customer …010 — **zero `pending_nurse_response`,
zero `accepted_awaiting_payment`**. So `/fa/nurse/requests` opens on an empty «در انتظار» tab and the
accept/reject buttons are unreachable on seeded data. (The live inbox now reads 14 / 12 because the probe rows
below — 21 accepted, 22 cancelled, 23 rejected — were left in place.) Create your own:

1. As …010 go to `/fa/search`, open نرس **زهرا عزیزی** (nurse 1, female, verified), press «درخواست رزرو».
2. On C4 pick patient حسن محمدی, service «مراقبت ساعتی سالمند» (variant 2, 250 000 IRR/hr), address «خانه»,
   a **future** date, a time window, gender «زن». Submit.
   **Expect:** redirect to `/fa/bookings/request/<newId>`, step 2 of 3 active, status «در انتظار تایید
   پرستار», a countdown against a deadline ~24 h out.
3. In another browser profile log in as **09120000001** (nurse 1) and open `/fa/nurse/requests`.
   **Expect:** the new request in «در انتظار» with the service headline, the patient name, the gender chip,
   your note — **and no address**. Open it: the location line reads «تهران · منطقه ۳» only.
4. Press «پذیرش».
   **Expect:** `200`, status flips to `accepted_awaiting_payment`, the row leaves «در انتظار», and the
   customer's C5 (polling at 15 s) shows the «پذیرفته شد» badge, a 30-minute countdown and «ادامه پرداخت».
5. Press «پذیرش» again (or reload and retry): **expect a `409`** and the «action_stale» toast.
6. For the reject leg, create a second request and press «رد درخواست» with a reason (≤500 chars).
   **Expect:** `200`, status `rejected_by_nurse`, the reason echoed back on the DTO, and C5 swapping to the
   rejection recovery card. A repeat reject is `409 "This request can no longer be rejected."`

Verified by curl on 2026-08-02: request 21 created → nurse-1 pending inbox `total 1` → accepted
(`paymentDeadlineAt` +30 min) → second accept `409`; request 22 created then cancelled by the customer
(`cancelled_by_customer`, `200`); request 23 created → **rejected** by nurse 1 (`200`,
`rejected_by_nurse`, `nurseRejectionReason` echoed) → second reject `409`. Tenancy re-checked live: nurse 2
reading request 21 → `404`. Every leg of this flow is now live-walked, not code-traced.

Re-walked independently the same day on a **fresh** row: request **24** created by …010 against nurse 1
(`200`, `pending_nurse_response`, `nurseResponseDeadlineAt` = `createdAt + 24 h` exactly) → accepted by
nurse 1 (`200`, `accepted_awaiting_payment`, `paymentDeadlineAt` = accept instant + **30 min** exactly,
`addressLine`/`recipientPhone` `null` in the nurse's own accept response) → second accept `409` → accept by
nurse 2 `404`. Same results, so the four load-bearing behaviours above are reproducible, not one-off.

curl gotcha: sending non-ASCII `customerNotes` as a `-d` argument from Git Bash produced a spurious `400`.
Put the JSON in a UTF-8 file and use `--data-binary @file`; it then returns `200` with the note intact.
This is a shell encoding artifact, **not** a server defect.

## Known gaps

- **Deadline timestamps lose their timezone, so every countdown on this flow is wrong off-UTC.**
  `BookingRequestDto.cs:39-40` and `BookingRequestListItemDto.cs:19-20` type the two deadlines as `DateTime`
  (Kind unspecified after the EF round-trip), so the wire carries `"2026-08-03T12:56:14.1701328"` with no
  `Z`. `CountdownTimer.tsx:84` calls `Date.parse` on it, which per spec reads an offset-less date-**time**
  as *local*. In Tehran (UTC+3:30) the customer's 30-minute payment window renders as ~4 hours and the
  request expires while the timer still shows time left. `createdAt` is a `DateTimeOffset` and *is* correct,
  so `windowStart` and `deadlineIso` on the same progress ring disagree by the UTC offset.
- **Nothing seeded is actionable.** No `pending`/`accepted` request survives, so the nurse inbox, the accept
  path, the reject path and the payment-window handoff are all unwalkable without creating a fresh request
  (see above). Cause: `DemoLifecycleSeeder` anchors to first-run epoch, not wall clock.
- **The `expire` admin endpoint is unreachable for the demo admins** — `AdminBookingRequestsController.cs:22`
  uses `DynamicPermission`, which `super_admin`/`finance` do not satisfy. Only the background
  `booking_request_expiry` job can move a stale request; a tester cannot force expiry on demand.
- **The «پاسخ‌داده» tab is unpaged.** The API filters one status at a time and there is no `status`-group
  filter, so `nurse/requests/page.tsx:37-39` fires three separate page-1 queries and concatenates them
  (`Pager` is hidden for that tab). A nurse with >20 accepted/converted/rejected requests silently loses
  rows. This is the surviving half of REQ-050.
- **No structured rejection-reason code (REQ-044).** The wire carries free-text `nurseRejectionReason`, so
  C5 runs a keyword heuristic (`RETRY_BLOCK_KEYWORDS`, `bookings/request/[id]/page.tsx:23-28` — seven
  strings: `gender`/`coverage`/`area`/«جنسیت»/«پوشش»/«منطقه»/«محدوده») to decide whether to offer «درخواست
  دوباره از همین پرستار». Any other wording defeats it — the live probe's `"schedule conflict"` passed through.
- **`services/bookingRequests/types.ts` is behind the wire.** `variantLabel` is typed `string | null`
  optional on the list item (`types.ts:137`) and documented as "client-augmented", but the server always
  serves it — confirmed live. `nurse/requests/page.tsx:135` therefore keeps a `hasPricedService` fallback
  branch that is dead on the real path.
- **The list row still lacks `variantPrice`/`variantPriceUnit`**, so the nurse inbox card cannot show the
  money she is being asked to commit to without opening the detail.
- **`patientAge` is served but never modelled client-side.** `BookingRequestListItemDto.cs:25` returns it and
  the wire carries it (live: `"patientAge": 78` on every nurse-inbox row), but `BookingRequestListItem`
  (`client/src/services/bookingRequests/types.ts:118-140`) declares no such field — `patientAge` appears
  nowhere in `client/src` except a comment in `apis/clientApi.ts:23`. REQ-014's triage age therefore never
  reaches the inbox card, and widening the client type is a pure-client fix.
- **`useCustomerRequests` does not poll** (no `refetchInterval`, `useCustomerRequests.ts:21-27`) while the
  detail hook does, so the pending-request countdowns on `/fa/bookings` go stale until a manual refresh.
- **`useBookingRequest` has no `useIsAuthenticated` gate** (unlike both list hooks), so a hard reload of C5
  before hydration can fire an unauthenticated `GET` and flash the error card.
- **`nurseAvatarUrl` is served as a `file:///C:/Users/…` local disk path** (verified live on requests 13, 21,
  22). C5 does not pass it to `BookingRequestSummaryCard`, so this flow is unaffected — but
  [checkout](checkout-and-payment.md) renders it and will show a broken avatar.
- The nurse sees the patient's **display name** pre-accept (`patientName`, verified live). That is wider than
  the "notes + coarse address only" wording in the integration doc; confirm it is the intended stage-1
  boundary rather than a leak.
