# 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/`, 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.