14 KiB
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 Integration: docs/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. 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 (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.
| 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 |
| 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) | 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) | 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 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:
- As …010 go to
/fa/search, open نرس زهرا عزیزی (nurse 1, female, verified), press «درخواست رزرو». - 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. - 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. - Press «پذیرش».
Expect:
200, status flips toaccepted_awaiting_payment, the row leaves «در انتظار», and the customer's C5 (polling at 15 s) shows the «پذیرفته شد» badge, a 30-minute countdown and «ادامه پرداخت». - Press «پذیرش» again (or reload and retry): expect a
409and the «action_stale» toast. - For the reject leg, create a second request and press «رد درخواست» with a reason (≤500 chars).
Expect:
200, statusrejected_by_nurse, the reason echoed back on the DTO, and C5 swapping to the rejection recovery card. A repeat reject is409 "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-40andBookingRequestListItemDto.cs:19-20type the two deadlines asDateTime(Kind unspecified after the EF round-trip), so the wire carries"2026-08-03T12:56:14.1701328"with noZ.CountdownTimer.tsx:84callsDate.parseon 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.createdAtis aDateTimeOffsetand is correct, sowindowStartanddeadlineIsoon the same progress ring disagree by the UTC offset. - Nothing seeded is actionable. No
pending/acceptedrequest 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:DemoLifecycleSeederanchors to first-run epoch, not wall clock. - The
expireadmin endpoint is unreachable for the demo admins —AdminBookingRequestsController.cs:22usesDynamicPermission, whichsuper_admin/financedo not satisfy. Only the backgroundbooking_request_expiryjob 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, sonurse/requests/page.tsx:37-39fires three separate page-1 queries and concatenates them (Pageris 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.tsis behind the wire.variantLabelis typedstring | nulloptional on the list item (types.ts:137) and documented as "client-augmented", but the server always serves it — confirmed live.nurse/requests/page.tsx:135therefore keeps ahasPricedServicefallback 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. patientAgeis served but never modelled client-side.BookingRequestListItemDto.cs:25returns it and the wire carries it (live:"patientAge": 78on every nurse-inbox row), butBookingRequestListItem(client/src/services/bookingRequests/types.ts:118-140) declares no such field —patientAgeappears nowhere inclient/srcexcept a comment inapis/clientApi.ts:23. REQ-014's triage age therefore never reaches the inbox card, and widening the client type is a pure-client fix.useCustomerRequestsdoes not poll (norefetchInterval,useCustomerRequests.ts:21-27) while the detail hook does, so the pending-request countdowns on/fa/bookingsgo stale until a manual refresh.useBookingRequesthas nouseIsAuthenticatedgate (unlike both list hooks), so a hard reload of C5 before hydration can fire an unauthenticatedGETand flash the error card.nurseAvatarUrlis served as afile:///C:/Users/…local disk path (verified live on requests 13, 21, 22). C5 does not pass it toBookingRequestSummaryCard, so this flow is unaffected — but checkout 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.