backend phase 8
This commit is contained in:
@@ -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).
|
||||
@@ -7,10 +7,75 @@
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "http://localhost"
|
||||
"url": "https://localhost:5002"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/api/v1/admin_booking_requests/expire": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"AdminBookingRequests"
|
||||
],
|
||||
"operationId": "AdminBookingRequests_Expire",
|
||||
"responses": {
|
||||
"400": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfExpireBookingRequestsResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/admin_catalog/create_category": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -2484,6 +2549,510 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/booking_requests/create": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"BookingRequests"
|
||||
],
|
||||
"summary": "Creates a BookingRequest",
|
||||
"operationId": "BookingRequests_Create",
|
||||
"requestBody": {
|
||||
"x-name": "command",
|
||||
"description": "A BookingRequest representation",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateBookingRequestCommand"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true,
|
||||
"x-position": 1
|
||||
},
|
||||
"responses": {
|
||||
"400": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfBookingRequestDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/booking_requests/cancel/{id}": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"BookingRequests"
|
||||
],
|
||||
"operationId": "BookingRequests_Cancel",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"x-position": 1
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"400": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfBookingRequestDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/booking_requests/accept/{id}": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"BookingRequests"
|
||||
],
|
||||
"operationId": "BookingRequests_Accept",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"x-position": 1
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"400": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfBookingRequestDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/booking_requests/reject/{id}": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"BookingRequests"
|
||||
],
|
||||
"operationId": "BookingRequests_Reject",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"x-position": 1
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"x-name": "command",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RejectBookingRequestCommand"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true,
|
||||
"x-position": 2
|
||||
},
|
||||
"responses": {
|
||||
"400": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfBookingRequestDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/booking_requests/list": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"BookingRequests"
|
||||
],
|
||||
"operationId": "BookingRequests_List",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Status",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"x-position": 1
|
||||
},
|
||||
{
|
||||
"name": "Role",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"x-position": 2
|
||||
},
|
||||
{
|
||||
"name": "Page",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"x-position": 3
|
||||
},
|
||||
{
|
||||
"name": "PageSize",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"x-position": 4
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"400": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfPagedResultOfBookingRequestListItemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/booking_requests/get/{id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"BookingRequests"
|
||||
],
|
||||
"summary": "Retrieves a BookingRequest by unique id",
|
||||
"operationId": "BookingRequests_Get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "A unique id for the BookingRequest",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"x-position": 1
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"400": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiResultOfBookingRequestDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/catalog/categories": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -3786,7 +4355,7 @@
|
||||
"tags": [
|
||||
"Me"
|
||||
],
|
||||
"description": "Role claims live inside the access token \u2014 after selecting a role the client should\n refresh its tokens to pick the new role up.",
|
||||
"description": "Role claims live inside the access token — after selecting a role the client should\n refresh its tokens to pick the new role up.",
|
||||
"operationId": "Me_SelectRole",
|
||||
"requestBody": {
|
||||
"x-name": "command",
|
||||
@@ -7200,6 +7769,41 @@
|
||||
500
|
||||
]
|
||||
},
|
||||
"ApiResultOfExpireBookingRequestsResult": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"data": {
|
||||
"nullable": true,
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ExpireBookingRequestsResult"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"ExpireBookingRequestsResult": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"expiredNoResponse": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"paymentDeadlineExpired": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResultOfServiceCategoryDto": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -8578,6 +9182,306 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResultOfBookingRequestDto": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"data": {
|
||||
"nullable": true,
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/BookingRequestDto"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"BookingRequestDto": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"nurseId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"nurseName": {
|
||||
"type": "string"
|
||||
},
|
||||
"nurseRating": {
|
||||
"type": "number",
|
||||
"format": "decimal"
|
||||
},
|
||||
"nurseTotalReviews": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"patientId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"patientName": {
|
||||
"type": "string"
|
||||
},
|
||||
"variantId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"variantLabel": {
|
||||
"type": "string"
|
||||
},
|
||||
"variantPriceUnit": {
|
||||
"type": "string"
|
||||
},
|
||||
"customerAddressId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"addressTitle": {
|
||||
"type": "string"
|
||||
},
|
||||
"cityId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"cityNameFa": {
|
||||
"type": "string"
|
||||
},
|
||||
"cityNameEn": {
|
||||
"type": "string"
|
||||
},
|
||||
"districtId": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"nullable": true
|
||||
},
|
||||
"districtNameFa": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"districtNameEn": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"addressLine": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"postalCode": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"recipientName": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"recipientPhone": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"requiredCaregiverGender": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"requestedDate": {
|
||||
"type": "string",
|
||||
"format": "date"
|
||||
},
|
||||
"requestedTimeStart": {
|
||||
"type": "string",
|
||||
"format": "time"
|
||||
},
|
||||
"requestedTimeEnd": {
|
||||
"type": "string",
|
||||
"format": "time"
|
||||
},
|
||||
"customerNotes": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"nurseResponseDeadlineAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"paymentDeadlineAt": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"nullable": true
|
||||
},
|
||||
"nurseRejectionReason": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CreateBookingRequestCommand": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"nurseId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"variantId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"patientId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"customerAddressId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"requestedDate": {
|
||||
"type": "string",
|
||||
"format": "date"
|
||||
},
|
||||
"requestedTimeStart": {
|
||||
"type": "string",
|
||||
"format": "time"
|
||||
},
|
||||
"requestedTimeEnd": {
|
||||
"type": "string",
|
||||
"format": "time"
|
||||
},
|
||||
"requiredCaregiverGender": {
|
||||
"type": "string"
|
||||
},
|
||||
"customerNotes": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"RejectBookingRequestCommand": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResultOfPagedResultOfBookingRequestListItemDto": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ApiResult"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"data": {
|
||||
"nullable": true,
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/PagedResultOfBookingRequestListItemDto"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"PagedResultOfBookingRequestListItemDto": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"nullable": true,
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/BookingRequestListItemDto"
|
||||
}
|
||||
},
|
||||
"total": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"page": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"pageSize": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"BookingRequestListItemDto": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"counterpartyName": {
|
||||
"type": "string"
|
||||
},
|
||||
"nurseRating": {
|
||||
"type": "number",
|
||||
"format": "decimal",
|
||||
"nullable": true
|
||||
},
|
||||
"requiredCaregiverGender": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"requestedDate": {
|
||||
"type": "string",
|
||||
"format": "date"
|
||||
},
|
||||
"requestedTimeStart": {
|
||||
"type": "string",
|
||||
"format": "time"
|
||||
},
|
||||
"requestedTimeEnd": {
|
||||
"type": "string",
|
||||
"format": "time"
|
||||
},
|
||||
"nurseResponseDeadlineAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"paymentDeadlineAt": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"nullable": true
|
||||
},
|
||||
"customerNotes": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"ApiResultOfPagedResultOfServiceCategoryDto": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -12,6 +12,34 @@ One block per completed backend phase. Newest at the top. Backend lane writes he
|
||||
- **Notes for frontend:** <anything load-bearing>
|
||||
-->
|
||||
|
||||
## backend-phase-8 — Booking requests (pre-payment intent) — 2026-07-06
|
||||
- **Shipped:** the money-free request lifecycle via one additive migration — new **`booking`** schema,
|
||||
**1 table** `BookingRequests`. The customer-side (`CreateBookingRequest` — tenancy invariant
|
||||
patient+address∈customer & variant∈nurse, bookability + **same-gender** match, response deadline frozen from
|
||||
`nurse_response_deadline_hours`; `CancelBookingRequest`), the nurse-side (`AcceptBookingRequest` — freezes
|
||||
`payment_deadline_at` from `booking_payment_deadline_minutes`=30, self-guards the response deadline;
|
||||
`RejectBookingRequest` — reason required), the reads (`ListBookingRequests` role-scoped inbox +
|
||||
`GetBookingRequest` party/admin, **nurse view masks the full address, exposes only `customer_notes`**), and
|
||||
the **forward-only status guard** (`BookingRequestTransitions`) + the idempotent, paginated
|
||||
`ExpireBookingRequests` sweep behind a recurring `BackgroundService` (`BookingRequestExpiryHostedService`,
|
||||
reuses the b1 `IJobScheduler` seam) + an admin manual trigger. **2 controllers:** `BookingRequestsController`
|
||||
(customer/nurse) + `AdminBookingRequestsController` (`POST admin_booking_requests/expire`). Inbox/expiry
|
||||
covering indexes `(nurse_id,status)`/`(customer_id,status)`/`(status,response_deadline)`/`(status,payment_deadline)`.
|
||||
**No money column, no `bookings` row, no snapshot, no price** anywhere.
|
||||
- **Contracts:** dev/contracts/domains/booking-requests.md + openapi snapshot refreshed (yes — the 7 booking
|
||||
paths + `BookingRequestDto`/`BookingRequestListItemDto`/`ExpireBookingRequestsResult`).
|
||||
- **Mocked:** **nothing new** — reuses `IPlatformConfig`/`INotificationDispatcher`/`IJobScheduler`/
|
||||
`IDateTimeProvider`/`ICurrentUser`. **No new `mocks-registry.md` row** (the `IJobScheduler` row was updated to
|
||||
note the second hosted job).
|
||||
- **Gate:** build clean (0 new code warnings) / tests green (215 pass: +20 booking = 14 handler-unit + the
|
||||
transition machine + 4 DB-backed SQLite + 5 API integration). Migration applied to the dev DB; API boots with
|
||||
all booking paths in Swagger; the expiry sweep runs on startup (verified against SQL Server).
|
||||
- **Handoff:** backend/handoff/after-backend-phase-8.md
|
||||
- **Notes for frontend:** f7-b8 = the request form (C4 → `booking_requests/create`), the status tracker (C5 →
|
||||
`booking_requests/get/{id}` + customer inbox with the response + 30-min payment countdowns + `cancel`), and
|
||||
the nurse inbox (`booking_requests/list?role=nurse` + `accept`/`reject`). Gender required (`male`/`female`/
|
||||
`any`); deadlines are server-frozen UTC timestamps; the nurse sees only `customer_notes` + a masked address.
|
||||
|
||||
## backend-phase-7 — Search & matching (nurse search index) — 2026-07-05
|
||||
- **Shipped:** the discovery layer via one additive migration — new **`search`** schema, **1 table**
|
||||
`NurseSearchIndices` (the denormalized `nurse_search_index`): **one flat row per (bookable variant ×
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Handoff — after backend-phase-8 (Booking requests · pre-payment intent)
|
||||
|
||||
**The booking-request lifecycle is live end-to-end.** A customer can request a nurse; the nurse accepts
|
||||
(opening a config-driven 30-minute payment window) or rejects; both sides read their role-scoped inbox and a
|
||||
single request; unanswered/unpaid requests auto-expire on a recurring sweep (also an admin manual trigger).
|
||||
**No money and no `bookings` row exist yet** — that conversion is b9/b10.
|
||||
|
||||
## What b9 must consume (the next backend phase)
|
||||
|
||||
- **Convert an `accepted_awaiting_payment` request → a `bookings` row** once payment is captured (b10), then
|
||||
set the request to `converted` **through the `BookingRequestTransitions` guard** (`request.MarkConverted()`
|
||||
already exists and is the only sanctioned path; b8 never writes this edge itself). The request↔booking link
|
||||
is 1:1 and b9-owned.
|
||||
- **b8 deliberately exposes only `customer_notes` (stage 1).** The full **encrypted** clinical/care
|
||||
instructions are b9's **stage 2** (`booking_care_instructions`, readable only post-confirmation by the
|
||||
assigned nurse + admin). Do **not** add an encrypted clinical field to `booking_requests`.
|
||||
- **The money split, snapshots, sessions, EVV, dispute window** are all b9/b10 — b8 persists **no**
|
||||
`variant_snapshot_json`/`address_snapshot_json`, no price, no ledger entry. The b5 `IVariantSnapshotSerializer`
|
||||
is still the tool for the booking snapshot at conversion time.
|
||||
- **Reuse the forward-only status-machine pattern** for the `bookings` state machine (see CONVENTIONS §6 —
|
||||
"Forward-only status machine"). Same shape: const codes + a static `CanTransition` edge table + entity-owned
|
||||
transitions + handler pre-check → 409.
|
||||
|
||||
## What f7-b8 can now build (frontend)
|
||||
|
||||
All routes are **action-style** (`[controller]/[action]`, snake_case) with the standard
|
||||
`{ succeeded, statusCode, data }` envelope. Full shapes in
|
||||
[`dev/contracts/domains/booking-requests.md`](../../contracts/domains/booking-requests.md); types come from the
|
||||
refreshed `swagger.v1.json` — **do not guess**.
|
||||
|
||||
- **Request form (C4)** → `POST api/v1/booking_requests/create` (customer). Body: `nurseId`, `variantId`,
|
||||
`patientId`, `customerAddressId`, `requestedDate` (`YYYY-MM-DD`), `requestedTimeStart`/`requestedTimeEnd`
|
||||
(`HH:mm:ss`), `requiredCaregiverGender` (`male`|`female`|`any`, **required**), `customerNotes` (≤ 1000, the
|
||||
**only** clinical text the nurse sees). Inputs come from search (nurse+variant), patients (b3), addresses (b4).
|
||||
- **Awaiting-acceptance / status tracker (C5)** → `GET api/v1/booking_requests/get/{id}` + the customer inbox
|
||||
`GET api/v1/booking_requests/list?role=customer`. Show two countdowns: to `nurseResponseDeadlineAt` (pending)
|
||||
and to `paymentDeadlineAt` (accepted — the 30-min window). Customer can `POST …/cancel/{id}` while pending or
|
||||
accepted-awaiting-payment.
|
||||
- **Nurse incoming-requests inbox** → `GET api/v1/booking_requests/list?role=nurse` (+ `status=` filter) with
|
||||
`POST …/accept/{id}` and `POST …/reject/{id}` (reason required). The nurse row shows `counterpartyName` =
|
||||
patient name + `customerNotes` only, plus the response countdown — **never** a full address or any clinical
|
||||
field beyond `customerNotes`.
|
||||
|
||||
## Rules the UI must respect
|
||||
|
||||
- **Same-gender is first-class and required.** Send `requiredCaregiverGender` explicitly (never default it);
|
||||
`male`/`female` must match the nurse's gender or the create is a `400`. `any` matches either.
|
||||
- **Deadlines are server-frozen absolute UTC timestamps.** Render countdowns from them; never recompute a
|
||||
deadline client-side. `paymentDeadlineAt` is null until the nurse accepts.
|
||||
- **Two-stage disclosure.** The nurse detail view returns a **masked** address (city/district only,
|
||||
`addressLine`/`postalCode`/recipient are null); the customer/admin view returns the full address. Do not
|
||||
expect (or request) clinical instructions here — they don't exist until b9.
|
||||
- **Status drives the UI.** Terminal states (`converted`/`rejected_by_nurse`/`expired_no_response`/
|
||||
`payment_deadline_expired`/`cancelled_by_customer`) allow no further actions; a stale action returns `409`.
|
||||
- **`get/{id}` is party-scoped.** A non-party caller gets `404` (existence not leaked).
|
||||
|
||||
## Contracts
|
||||
|
||||
- New: [`dev/contracts/domains/booking-requests.md`](../../contracts/domains/booking-requests.md).
|
||||
- `swagger.v1.json` refreshed (adds `booking_requests/{create,accept,reject,cancel,list,get}` +
|
||||
`admin_booking_requests/expire` + `BookingRequestDto`/`BookingRequestListItemDto`/`ExpireBookingRequestsResult`).
|
||||
|
||||
## Nothing new is mocked
|
||||
|
||||
b8 introduces **no** external seam and adds **no** new `mocks-registry.md` row. It reuses `IPlatformConfig`,
|
||||
`INotificationDispatcher`, `IJobScheduler`/`BackgroundService`, `IDateTimeProvider`, `ICurrentUser`. The
|
||||
expiry sweep is an internal hosted service (the registry's `IJobScheduler` row was updated to note the second
|
||||
job), not an external integration.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Backend phase 8 report — Booking requests (pre-payment intent)
|
||||
|
||||
**Date:** 2026-07-06 · **Track:** backend · **Depends on:** b1 (config/jobs/notifications), b3 (profiles/
|
||||
patients/tenancy), b4 (addresses), b5 (variants), b7 (search/gender). **Unlocks:** b9 (bookings/sessions/care)
|
||||
and frontend f7-b8.
|
||||
|
||||
## What was built
|
||||
|
||||
The **money-free** first half of the engagement lifecycle — the `booking_requests` table and its full state
|
||||
machine (request → accept → pay-window → expire/reject/cancel). **One additive migration** adds a new
|
||||
**`booking`** schema with a single table `BookingRequests`. **No money, no `bookings` row, no snapshot, no
|
||||
price** anywhere in this phase.
|
||||
|
||||
- **Domain** (`Baya.Domain/Entities/Booking/`): `BookingRequest` (guarded `status` with a private setter, both
|
||||
deadlines as UTC `DateTime`, unencrypted `CustomerNotes`), `BookingRequestStatus` (7 const codes),
|
||||
`BookingRequestTransitions` (forward-only edge table + `CanTransition`), `CaregiverGender` (`male`/`female`/
|
||||
`any` + `Matches`).
|
||||
- **Application** (`Features/Booking/`): commands `CreateBookingRequest`, `AcceptBookingRequest`,
|
||||
`RejectBookingRequest`, `CancelBookingRequest`, `ExpireBookingRequests`; queries `ListBookingRequests`
|
||||
(role-scoped) + `GetBookingRequest` (party/admin); `BookingRequestMapper` (stage-1 address masking);
|
||||
DTOs in `Models/Booking/`; `NurseBookingContext` in `Models/Identity/`.
|
||||
- **Infrastructure**: `BookingRequestConfig` (EF config + the 4 covering indexes + soft-delete filter),
|
||||
`BookingRequestRepository` on `IUnitOfWork`, `INurseProfileRepository.GetBookingContextByIdAsync`, and
|
||||
`BookingRequestExpiryHostedService` (recurring sweep, reuses the b1 `IJobScheduler`/`BackgroundService` seam),
|
||||
registered in `AddPersistenceServices`.
|
||||
- **API** (`Controllers/V1/`): `BookingRequestsController` (`create`/`accept/{id}`/`reject/{id}`/`cancel/{id}`/
|
||||
`list`/`get/{id}`, `[Authorize]`, role/ownership enforced in handlers) + `AdminBookingRequestsController`
|
||||
(`expire`, `DynamicPermission`).
|
||||
|
||||
## The critical rules, as enforced
|
||||
|
||||
- **No money / no booking.** A request has no price column; accept only sets `payment_deadline_at`. Conversion
|
||||
to a `bookings` row is b9 (`MarkConverted()` exists but b8 never calls it).
|
||||
- **Two-stage disclosure (stage 1).** `customer_notes` is unencrypted and the only clinical text the nurse
|
||||
sees; the nurse view/inbox mask the encrypted full address to a coarse city/district.
|
||||
- **Tenancy invariant** resolved from `ICurrentUser` (never the body): patient + address ∈ the caller's
|
||||
customer, variant ∈ the requested nurse — a mismatch is a clean 404.
|
||||
- **Same-gender match** at request time against `User.Gender`; required, never defaulted.
|
||||
- **Deadlines frozen from config** (`nurse_response_deadline_hours` at create, `booking_payment_deadline_minutes`
|
||||
= 30 at accept) as absolute UTC timestamps; a later config change can't move them.
|
||||
- **Forward-only guard**: every write pre-checks `CanTransition` → 409 on an illegal edge; terminal states have
|
||||
no outgoing edge. Accept self-guards against a passed response deadline. The expiry sweep is bounded,
|
||||
paginated, time-injected, and idempotent (the `WHERE status = …` predicate is the concurrency guard).
|
||||
|
||||
## What is now testable, and exactly how
|
||||
|
||||
Run the API against a reachable SQL Server (`dotnet run --project src/API/Baya.Web.Api/...`), sign in per role.
|
||||
The §7 scenarios all pass:
|
||||
|
||||
1. **Create (happy path)** — customer `POST /v1/booking_requests/create` with own patient/address, a
|
||||
verified+accepting nurse's active variant, a future date, `requiredCaregiverGender: any` → `200`,
|
||||
`pending_nurse_response`, `nurseResponseDeadlineAt = now + nurse_response_deadline_hours`, `paymentDeadlineAt`
|
||||
null; the nurse gets a `booking_request_received` notification.
|
||||
2. **Cross-customer patient/address/variant** → clean `404`, no row created.
|
||||
3. **Same-gender mismatch** (`female` vs a `male` nurse) → `400`; `any` succeeds.
|
||||
4. **Accept** → `200`, `accepted_awaiting_payment`, `paymentDeadlineAt = now + 30 min`, customer notified; no
|
||||
bookings row.
|
||||
5. **Reject** with reason → `200`, `rejected_by_nurse`; rejecting a non-pending request → `409`.
|
||||
6. **Nurse inbox** (`list?role=nurse`) shows `customerNotes` + the response countdown, no clinical/encrypted field.
|
||||
7. **Customer cancels** an accepted request → `200`, `cancelled_by_customer`; cancelling a terminal one → `409`.
|
||||
8. **Expiry** — admin `POST /v1/admin_booking_requests/expire` (or the recurring job) moves stale pending →
|
||||
`expired_no_response` and stale accepted → `payment_deadline_expired`, notifies the customer; re-running is a
|
||||
no-op.
|
||||
9. **Read tenancy** — a third party `GET /v1/booking_requests/get/{id}` → `404`.
|
||||
|
||||
**Automated tests (215 total pass):** 14 handler-unit (`CreateBookingRequestHandlerTests`,
|
||||
`RespondBookingRequestHandlerTests`) + the transition-machine tests (`BookingRequestTransitionsTests`) + 4
|
||||
DB-backed SQLite tests over the real EF model (`BookingRequestExpiryTests`, `BookingRequestQueryTests` via
|
||||
`BookingTestHost`) + 5 API integration tests (`BookingRequestsApiTests`: 401/400/empty-inbox/admin-expire).
|
||||
`dotnet build Baya.sln` = 0 new warnings; `dotnet test Baya.sln` green. Migration applied to the dev DB and the
|
||||
sweep verified running against SQL Server on boot.
|
||||
|
||||
## Contracts produced / consumed
|
||||
|
||||
- **Produced:** `dev/contracts/domains/booking-requests.md`; `swagger.v1.json` refreshed (the 7 booking paths +
|
||||
the 3 DTOs).
|
||||
- **Consumed:** b1 config keys (`nurse_response_deadline_hours`, `booking_payment_deadline_minutes`), b3
|
||||
profiles/patients + tenancy, b4 addresses, b5 variants (bookable unit; `GetOwnedAsync` tenancy), b7 nurse
|
||||
gender/matching data.
|
||||
|
||||
## Nothing is mocked here
|
||||
|
||||
This phase owns **no** third-party integration and introduces **no** DI seam. It reuses `IPlatformConfig`,
|
||||
`INotificationDispatcher`, `IJobScheduler`/`BackgroundService`, `IDateTimeProvider`, `ICurrentUser`. There is
|
||||
**no new `mocks-registry.md` row** — the existing `IJobScheduler` row was updated to note the second hosted job
|
||||
(the internal expiry sweep is not an external seam).
|
||||
|
||||
## Follow-ups for b9 (+ b10)
|
||||
|
||||
- Consume an `accepted_awaiting_payment` request → create the `bookings` row on payment capture (b10), then
|
||||
`request.MarkConverted()` through the guard. The request↔booking link is 1:1 and b9-owned.
|
||||
- Stage 2: the encrypted `booking_care_instructions` (post-confirmation, assigned nurse + admin only). Do not
|
||||
add a clinical field to `booking_requests`.
|
||||
- The three-amount money split, `variant_snapshot_json`/`address_snapshot_json`, `booking_sessions`, EVV, and
|
||||
`dispute_window_ends_at` are all b9/b10.
|
||||
- Reuse the forward-only status-machine pattern (CONVENTIONS §6) for the `bookings` state machine.
|
||||
@@ -22,7 +22,7 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢
|
||||
| `IBankTransferProvider` | backend-phase-13 | PAYA/SATNA payout — fake transfer ref | _tbd_ | Jibit/Vandar/Sadad payout; source account; PAYA vs SATNA | 🔴 |
|
||||
| `IHolidayCalendar` | backend-phase-1 | Bank holidays — reads the seeded `ops.IranianHolidays` table; lookups cached (`HolidayCalendarService`, `Persistence/Services/Holidays/`); Iranian banking weekend = Friday | _none_ | Add a sync job/feed that maintains the (partly lunar-Hijri) calendar table; the read interface stays | 🟡 |
|
||||
| `IAnalyticsSink` | backend-phase-1 | Behavioural events — inserts an `ops.SystemEvents` row, fire-and-forget (`AnalyticsSink`, `Persistence/Services/Analytics/`) | _none_ | Pipe to a warehouse/stream (e.g. Kafka→ClickHouse); keep fire-and-forget semantics | 🟡 |
|
||||
| `IJobScheduler` (retention) | backend-phase-1 | Scheduling — in-process interval `BackgroundService` running `PurgeOldReadNotifications` daily (`NotificationRetentionHostedService`, `Persistence/Services/Notifications/`) | _none_ | Swap to Hangfire/Quartz; register the job there; keep the purge predicate (`is_read=1 AND age>90d`) | 🟡 |
|
||||
| `IJobScheduler` (retention + booking expiry) | backend-phase-1 | Scheduling — in-process interval `BackgroundService`s: `PurgeOldReadNotifications` daily (`NotificationRetentionHostedService`, `Persistence/Services/Notifications/`) and **b8** `BookingRequestExpiryHostedService` (`Persistence/Services/Booking/`) running the idempotent booking-request expiry sweep every minute | _none_ | Swap to Hangfire/Quartz; register **both** jobs there; keep the purge predicate (`is_read=1 AND age>90d`) and the booking-expiry command | 🟡 |
|
||||
| `IShahkarVerifier` | backend-phase-6 | شاهکار phone↔national-id binding — `MockShahkarVerifier` (`Baya.Infrastructure.CrossCutting/Seams/`) returns a deterministic result + fake vendor ref + `external_response_json`: matches every pair except the configured shared-SIM phone (→ the explicit shared-SIM failure state, which the handler turns into a `shared_sim` support alert) and the mismatch national id (→ plain mismatch); registered singleton in `AddCrossCuttingSeams`. No real Shahkar call | `Seams:Shahkar:SharedSimPhone` (default `09120000000`), `Seams:Shahkar:MismatchNationalId` (default `1111111111`) | 1) pick a Finnotech / KYC Shahkar-bridge vendor, add its client package to `Directory.Packages.props`; 2) add `Seams:Shahkar:{ApiKey,BaseUrl}` options; 3) implement `MatchAsync(phone, nationalId)` against the real استعلام شاهکار, mapping to `ShahkarMatchResult` and persisting the raw response into the step's `external_response_json`; 4) keep shared-SIM as the explicit handled failure (`IsSharedSim=true`); 5) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 6) test match / shared-SIM / mismatch + that a phone change re-runs it (`shahkar_verified_at` resets upstream on phone change) | 🟡 |
|
||||
| `IIdentityKycProvider` | backend-phase-6 | Identity KYC (national-id validity + name match + liveness) — `MockIdentityKycProvider` (`.../Seams/`) passes any well-formed 10-digit national id except the configured fail id, returning a matched name + fake vendor ref + `external_response_json`; on pass the handler populates `users.national_id` + `national_id_verified_at`. No real OCR/liveness; registered singleton | `Seams:IdentityKyc:FailNationalId` (default `0000000000`), `Seams:IdentityKyc:MatchedName` (default `Verified Nurse`) | 1) pick an Iranian e-KYC vendor (Finnotech / U-ID / Jibbit / Farashensa / Verify / Kavoshak), add its client package to `Directory.Packages.props`; 2) add `Seams:IdentityKyc:{ApiKey,BaseUrl}` options; 3) implement `VerifyAsync(nationalId, livenessPayload)` → national-id validity + name match + photo/video liveness against ثبت احوال, mapping to `IdentityKycResult` and persisting `external_response_json`; 4) swap the registration (config-selected) — handlers unchanged; 5) test pass/fail by national id + that `national_id` is populated **only** on pass | 🟡 |
|
||||
| `ICredentialVerifier` | backend-phase-6 | MoH پروانه صلاحیت حرفهای / INO / عدم سوء پیشینه verification — `MockCredentialVerifier` (`.../Seams/`) **is** the manual-admin default: every call returns `RequiresManualReview` with `verification_method=manual` (an admin verifies the uploaded document against the official portal in `AdminReviewStep`). No portal call; registered singleton. There is **no public B2B API** for MoH/INO, so this stays manual until one appears | _none_ | 1) when an MoH/INO portal or API becomes available, implement `VerifyAsync(credentialType, credentialNumber)` to return `Verified`/`Failed` with `verification_method=portal|api` (+ `external_response_json`); 2) swap the registration (config-selected) for those credential types — the manual path stays the fallback; 3) the structured `nurse_credentials` registry already stores number/authority/expiry so cross-check + renewal survive the swap. **MoH/INO have no public B2B API today** | 🟡 |
|
||||
|
||||
Reference in New Issue
Block a user