backend phase 8

This commit is contained in:
hamid
2026-07-06 02:48:56 +03:30
parent 99ebf5d881
commit 2cfc082a04
55 changed files with 7480 additions and 7 deletions
+148
View File
@@ -0,0 +1,148 @@
# 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<BookingRequestListItemDto>` (`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).