# Contract — Booking requests (backend phase b8) > The **pre-payment intent** layer of the engagement lifecycle: a customer requests a nurse; the nurse > accepts/rejects before a config-driven response deadline; on accept a config-driven **30-minute payment > window** opens. **No money and no `bookings` row exist here** — accept only opens the window (conversion is > b9/b10). Assumes [`../conventions/api-conventions.md`](../conventions/api-conventions.md) + > [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema: > [`../openapi/swagger.v1.json`](../openapi/README.md) (refreshed for b8). **Status:** live as of backend-phase-b8 · **Frontend consumer:** frontend-phase-f7-b8 > **Routing note.** Routes are **action-style** (`[controller]/[action]`, snake_cased). All responses use the > standard `{ succeeded, statusCode, data }` envelope; `data` shapes are below. Request bodies are camelCase. ## Key semantics (read first) - **Two-table split, no money on a request.** A `booking_requests` row never carries a price/total and never creates a booking. Accept moves it to `accepted_awaiting_payment` and opens the payment window; b9 later consumes that and creates the `bookings` row (+ money), setting the request to `converted`. - **Two-stage clinical disclosure (stage 1).** The nurse sees **only** the unencrypted, limited `customer_notes` — never a full clinical/care instruction (those are b9's encrypted, post-confirmation `booking_care_instructions`). The **nurse view of a request masks the full address** (address line / postal code / recipient) and shows only a coarse city/district location; the **customer/admin view** returns the full address. - **Tenancy invariant (enforced at create).** The `patient` and `customer_address` must belong to the caller's customer; the `variant` must belong to the requested `nurse_id`. A mismatch is a clean `404` — never a leak. - **Same-gender match is first-class.** `required_caregiver_gender` (`male`/`female`/`any`) is matched against the nurse's gender at request time. `male`/`female` must equal the nurse's gender; `any` matches either. A mismatch is a `400`. It is required on create and never silently defaulted. - **Deadlines are frozen from config.** `nurse_response_deadline_at` = create-time `now + nurse_response_deadline_hours` (default 24h); `payment_deadline_at` = accept-time `now + booking_payment_deadline_minutes` (**30**). Both are absolute UTC timestamps stored on the row — a later config change never moves an existing request's deadlines. - **Forward-only status machine.** Illegal transitions return `409`. Terminal states (`converted`/`rejected_by_nurse`/`expired_no_response`/`payment_deadline_expired`/`cancelled_by_customer`) have no outgoing edges. An accept after the response deadline, or on a non-pending request, is `409`. - **Auto-expiry.** A background sweep (also an admin manual trigger) moves `pending_nurse_response → expired_no_response` past the response deadline and `accepted_awaiting_payment → payment_deadline_expired` past the payment window, and notifies the customer. - **Timestamps** are UTC ISO-8601. **Ids** are `BIGINT`. **There is no money field anywhere in this domain.** ## Enums used - `booking_request_status`: `pending_nurse_response` | `accepted_awaiting_payment` | `converted` | `rejected_by_nurse` | `expired_no_response` | `payment_deadline_expired` | `cancelled_by_customer`. - `required_caregiver_gender`: `male` | `female` | `any`. ## Endpoints ### `POST api/v1/booking_requests/create` - **Purpose:** a customer requests a nurse for a patient/variant/address/date. - **Auth:** authenticated **customer (owner)** · **Rate-limited:** no · **Idempotency key:** no - **Request body:** ```json { "nurseId": 42, "variantId": 8, "patientId": 5, "customerAddressId": 6, "requestedDate": "2026-08-01", "requestedTimeStart": "09:00:00", "requestedTimeEnd": "13:00:00", "requiredCaregiverGender": "female", "customerNotes": "Careful with the IV line." } ``` - **Success `200` payload (`data`):** a `BookingRequestDto` (customer view — full address), status `pending_nurse_response`, `nurseResponseDeadlineAt` set, `paymentDeadlineAt` null. - **Failure cases:** `400` validation (missing/invalid gender, `requestedTimeEnd ≤ requestedTimeStart`, past date, notes > 1000, inactive variant, nurse not verified/accepting, **same-gender mismatch**); `401` unauthenticated; `403` not a customer / no customer profile; `404` patient/address not owned or variant not the nurse's or nurse absent. - **Side effects:** in-app `booking_request_received` notification to the nurse (`data_json`: `booking_request_id`, `patient_display_name`, `requested_date`). ### `POST api/v1/booking_requests/accept/{id}` - **Purpose:** the assigned nurse accepts a pending request, opening the 30-minute payment window. - **Auth:** authenticated **nurse (assigned)** · path param `id` (BIGINT). - **Request body:** none. - **Success `200` payload (`data`):** `BookingRequestDto` (nurse view — masked address), status `accepted_awaiting_payment`, `paymentDeadlineAt = now + booking_payment_deadline_minutes` (30 min). - **Failure cases:** `401`; `403` not a nurse; `404` not the caller's request; `409` not pending / already past the response deadline. - **Side effects:** in-app `booking_request_accepted` notification to the customer (`data_json`: `booking_request_id`, `payment_deadline_at`). **No `bookings` row and no money are created.** ### `POST api/v1/booking_requests/reject/{id}` - **Purpose:** the assigned nurse declines a pending request with a reason. - **Auth:** authenticated **nurse (assigned)** · path param `id`. - **Request body:** `{ "reason": "Fully booked that week." }` (required, ≤ 500 chars). - **Success `200` payload (`data`):** `BookingRequestDto`, status `rejected_by_nurse`, `nurseRejectionReason` set. - **Failure cases:** `400` empty/too-long reason; `401`; `403`; `404`; `409` not pending. - **Side effects:** in-app `booking_request_rejected` notification to the customer. ### `POST api/v1/booking_requests/cancel/{id}` - **Purpose:** the customer withdraws a request that is still `pending_nurse_response` or `accepted_awaiting_payment` (before paying). - **Auth:** authenticated **customer (owner)** · path param `id`. - **Request body:** none. - **Success `200` payload (`data`):** `BookingRequestDto` (customer view), status `cancelled_by_customer`. - **Failure cases:** `401`; `403`; `404` not owned; `409` request is terminal (converted/rejected/expired). ### `GET api/v1/booking_requests/list` - **Purpose:** the role-scoped inbox (paginated). - **Auth:** authenticated (customer **or** nurse). - **Query params:** `status` (optional enum filter); `role` (`customer`|`nurse`, optional — disambiguates a user who holds both roles; inferred from the caller's profile when omitted); `page`/`pageSize` (default 1 / 50, max 100). - **Success `200` payload (`data`):** `PagedResult` (`items`, `total`, `page`, `pageSize`). Actionable rows sort first. The **customer** inbox sets `counterpartyName` = nurse name + `nurseRating`; the **nurse** inbox sets `counterpartyName` = patient name + `customerNotes` (stage-1 only). - **Failure cases:** `400` holds both roles and no `role` given; `401`. A caller with neither profile gets an empty page. ### `GET api/v1/booking_requests/get/{id}` - **Purpose:** a single request. - **Auth:** its **customer** (full address), its **nurse** (masked address), or an **admin** (full). - **Success `200` payload (`data`):** `BookingRequestDto`. - **Failure cases:** `401`; `404` absent **or** caller is neither party nor admin (existence not leaked). ### `POST api/v1/admin_booking_requests/expire` - **Purpose:** admin/test manual trigger of the expiry sweep (the same command the recurring job runs). - **Auth:** **admin** (`DynamicPermission`) · **Rate-limited:** no. - **Request body:** none. - **Success `200` payload (`data`):** `{ "expiredNoResponse": 0, "paymentDeadlineExpired": 0 }` — counts moved this run (idempotent; re-running on a drained set returns zeros). - **Failure cases:** `401`; `403` non-admin. ## Shared shapes - `BookingRequestDto` (full single-request view): `id` (long), `status` (enum), `nurseId` (long), `nurseName` (string), `nurseRating` (decimal), `nurseTotalReviews` (int), `patientId` (long), `patientName` (string), `variantId` (long), `variantLabel` (string), `variantPriceUnit` (string), `customerAddressId` (long), `addressTitle` (string), `cityId` (long), `cityNameFa`/`cityNameEn` (string), `districtId` (long?, null = whole city), `districtNameFa`/`districtNameEn` (string?), `addressLine`/`postalCode`/`recipientName`/`recipientPhone` (string?, **null in the nurse view** — masked), `requiredCaregiverGender` (enum?), `requestedDate` (date), `requestedTimeStart`/`requestedTimeEnd` (time), `customerNotes` (string?, stage-1 plaintext), `nurseResponseDeadlineAt` (UTC datetime), `paymentDeadlineAt` (UTC datetime?, null until accept), `nurseRejectionReason` (string?), `createdAt` (UTC datetime). - `BookingRequestListItemDto` (inbox row): `id`, `status`, `counterpartyName` (nurse name for customer view / patient name for nurse view), `nurseRating` (decimal?, customer view only), `requiredCaregiverGender` (enum?), `requestedDate`, `requestedTimeStart`, `requestedTimeEnd`, `nurseResponseDeadlineAt`, `paymentDeadlineAt`, `customerNotes` (string?, **nurse view only**). - `ExpireBookingRequestsResult`: `expiredNoResponse` (int), `paymentDeadlineExpired` (int). ## Changelog - b8 — initial contract (create/accept/reject/cancel + role-scoped list + single get + admin expire).