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** | 🟡 |
|
||||
|
||||
+29
-4
@@ -81,15 +81,15 @@ projects/assemblies, Clean-Architecture layers, and cross-layer dependencies.
|
||||
```
|
||||
src/
|
||||
├── Core/
|
||||
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker)
|
||||
│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); Search area = the b7 discovery query + admin index-rebuild; + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers)
|
||||
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), Booking/ (BookingRequest — the money-free pre-payment intent + BookingRequestStatus/BookingRequestTransitions forward-only status guard + CaregiverGender codes), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker)
|
||||
│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); Search area = the b7 discovery query + admin index-rebuild; Booking area = the b8 booking-request lifecycle (create/accept/reject/cancel + role-scoped inbox/detail + the expiry sweep command); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers)
|
||||
├── Infrastructure/
|
||||
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service + Search/ = SearchIndexMaintainer + SqlNurseSearch)
|
||||
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/ + BookingConfig/), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service + Search/ = SearchIndexMaintainer + SqlNurseSearch + Booking/ = BookingRequestExpiryHostedService)
|
||||
│ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/)
|
||||
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier + MockShahkarVerifier + MockIdentityKycProvider + MockCredentialVerifier) + AddCrossCuttingSeams
|
||||
│ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net
|
||||
├── API/
|
||||
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch), appsettings*.json
|
||||
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch + customer/nurse BookingRequests + admin AdminBookingRequests), appsettings*.json
|
||||
│ ├── Baya.WebFramework BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting)
|
||||
│ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models (User only)
|
||||
├── Shared/Baya.SharedKernel Extensions + validation base
|
||||
@@ -226,6 +226,31 @@ Two seams live in `Application/Contracts/Search/`, registered by `AddPersistence
|
||||
row on re-upsert so each (variant × area) has exactly one live row.
|
||||
- **Incremental maintenance and full rebuild must converge** — the index is fully re-derivable from source.
|
||||
|
||||
**Booking requests — pre-payment intent (backend-phase-8).** A new **`booking` schema** holds the single
|
||||
table `BookingRequests` — the **money-free** first half of the engagement lifecycle (`bookings` + money are
|
||||
b9/b10). One customer requests one nurse for a patient/variant/address/date; the nurse accepts (opening a
|
||||
30-minute payment window) or rejects before a frozen response deadline; unanswered/unpaid requests auto-expire.
|
||||
Features under `Baya.Application/Features/Booking/{Commands|Queries}/`; config in
|
||||
`Persistence/Configuration/BookingConfig/`; per-domain repo (`IBookingRequestRepository`) on `IUnitOfWork`;
|
||||
the recurring sweep is `Persistence/Services/Booking/BookingRequestExpiryHostedService` (reuses the b1
|
||||
`IJobScheduler`/`BackgroundService` seam). Load-bearing rules:
|
||||
- **No money, ever, and no `bookings` row.** A request carries no price/total; accept only opens the payment
|
||||
window. b9 consumes an `accepted_awaiting_payment` request → creates the booking → sets it `converted`.
|
||||
- **Two-stage clinical disclosure (stage 1).** The nurse sees **only** the unencrypted, limited `customer_notes`
|
||||
(never routed through `IFieldEncryptor`); the nurse view of a request **masks the full address** (line/postal/
|
||||
recipient) to a coarse city/district. The encrypted `booking_care_instructions` are b9's stage 2.
|
||||
- **Tenancy invariant.** patient + address ∈ the caller's `customer_id`; variant ∈ the requested `nurse_id`.
|
||||
Resolved from `ICurrentUser`, never the body; a mismatch is a clean 404.
|
||||
- **Same-gender match at request time.** `required_caregiver_gender` (`male`/`female`/`any`) is matched against
|
||||
the nurse's `User.Gender`; required on create, never silently defaulted.
|
||||
- **Deadlines frozen from config.** `nurse_response_deadline_at` = `now + nurse_response_deadline_hours` at
|
||||
create; `payment_deadline_at` = `now + booking_payment_deadline_minutes` (30) at accept — both stored as
|
||||
absolute UTC `datetime2` so a later config change can't move them. Stored as `DateTime` (not `DateTimeOffset`)
|
||||
because they are compared/sorted in queries and the SQLite test provider can't translate `DateTimeOffset`.
|
||||
- **Forward-only status guard** (`BookingRequestTransitions`) — every write is pre-checked; an illegal edge is a
|
||||
409, terminal states have no outgoing edge; the expiry sweep's `WHERE status = …` predicate is the concurrency
|
||||
guard (a row a racing accept/cancel moved is simply not reloaded). See CONVENTIONS §6.
|
||||
|
||||
**Keeping the Project map current.** When a change touches the architecture — adds, removes, or
|
||||
renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer
|
||||
dependency — you **must** update this Project map (and the dependency rule above, if affected) in the
|
||||
|
||||
@@ -332,6 +332,30 @@ rules this establishes:
|
||||
and the wire carry `in_review`, not `InReview`. Enum→code mapping in a projected read happens **in memory
|
||||
after materialization** (`.ToCode()` is not LINQ-translatable); DTOs expose the code string.
|
||||
|
||||
### Forward-only status machine (backend-phase-8)
|
||||
|
||||
When an entity has a lifecycle `status` with a fixed set of allowed transitions, model the machine as a
|
||||
**static allowed-edges table** and route **every** write through it — never assign `status` ad-hoc. The b8
|
||||
pattern (reused by b9 for the `bookings` machine):
|
||||
|
||||
- **Statuses are `const string` codes** (`BookingRequestStatus`) persisted as the stable snake_case string —
|
||||
no C# enum, no value converter needed. **Edges live in a static `CanTransition(from, to)`**
|
||||
(`BookingRequestTransitions`) built from a `Dictionary<string, IReadOnlyCollection<string>>`; terminal
|
||||
states map to an empty set.
|
||||
- **The entity owns the transition.** `status` has a **private setter**; the only mutators are cohesive domain
|
||||
methods (`Accept`/`Reject`/`Cancel…`) that call a private `Transition(target)` which asserts the edge is
|
||||
legal (throws on an illegal edge — a programming error, since the handler pre-checks). Side-effect fields
|
||||
(`payment_deadline_at`, `rejection_reason`) are set in the same method.
|
||||
- **The handler pre-checks and returns a clean 409.** `if (!entity.CanTransitionTo(target)) return
|
||||
OperationResult.ConflictResult(...)` — never throw for the expected "already moved / terminal" case.
|
||||
- **Time-sensitive commands self-guard** against a passed deadline via `IDateTimeProvider` rather than trusting
|
||||
a sweep has run; the recurring expiry `BackgroundService` is bounded/paginated/idempotent, and its
|
||||
`WHERE status = …` predicate (re-queried each tick) is the concurrency guard — a row a racing action moved is
|
||||
simply not reloaded.
|
||||
- **Deadline columns that are compared/sorted use `DateTime` (UTC `datetime2`), not `DateTimeOffset`** — the
|
||||
SQLite test provider cannot translate `DateTimeOffset` comparison/`ORDER BY`. Order lists/sweeps by `Id`, not
|
||||
the timestamp, for the same reason.
|
||||
|
||||
---
|
||||
|
||||
## 7. Validation
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Asp.Versioning;
|
||||
using Baya.Application.Features.Booking.Commands.ExpireBookingRequests;
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Infrastructure.Identity.Identity.PermissionManager;
|
||||
using Baya.WebFramework.Attributes;
|
||||
using Baya.WebFramework.BaseController;
|
||||
using Mediator;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Baya.Web.Api.Controllers.V1;
|
||||
|
||||
/// <summary>
|
||||
/// Admin manual trigger for the booking-request expiry sweep — fires the same idempotent command the
|
||||
/// recurring <c>BackgroundService</c> runs, so a human/test can expire stale requests on demand without
|
||||
/// waiting for the interval.
|
||||
/// </summary>
|
||||
[ApiVersion("1")]
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/[controller]")]
|
||||
[Authorize(ConstantPolicies.DynamicPermission)]
|
||||
[Display(Description = "Admin booking-request maintenance (manual expiry sweep)")]
|
||||
public sealed class AdminBookingRequestsController(ISender sender) : BaseController
|
||||
{
|
||||
[HttpPost("[action]")]
|
||||
[ProducesOkApiResponseType<ExpireBookingRequestsResult>]
|
||||
public async Task<IActionResult> Expire(CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new ExpireBookingRequestsCommand(), cancellationToken));
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Asp.Versioning;
|
||||
using Baya.Application.Features.Booking.Commands.AcceptBookingRequest;
|
||||
using Baya.Application.Features.Booking.Commands.CancelBookingRequest;
|
||||
using Baya.Application.Features.Booking.Commands.CreateBookingRequest;
|
||||
using Baya.Application.Features.Booking.Commands.RejectBookingRequest;
|
||||
using Baya.Application.Features.Booking.Queries.GetBookingRequest;
|
||||
using Baya.Application.Features.Booking.Queries.ListBookingRequests;
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.WebFramework.Attributes;
|
||||
using Baya.WebFramework.BaseController;
|
||||
using Mediator;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Baya.Web.Api.Controllers.V1;
|
||||
|
||||
/// <summary>
|
||||
/// The pre-payment booking-request lifecycle: a customer creates/cancels a request; the assigned nurse
|
||||
/// accepts/rejects; both parties read their role-scoped inbox and a single request. No money and no
|
||||
/// <c>bookings</c> row exist here — accept only opens the 30-minute payment window (converted in b9).
|
||||
/// </summary>
|
||||
[ApiVersion("1")]
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/[controller]")]
|
||||
[Authorize]
|
||||
[Display(Description = "Pre-payment booking requests (create/accept/reject/cancel + role-scoped inbox)")]
|
||||
public sealed class BookingRequestsController(ISender sender) : BaseController
|
||||
{
|
||||
[HttpPost("[action]")]
|
||||
[ProducesOkApiResponseType<BookingRequestDto>]
|
||||
public async Task<IActionResult> Create(CreateBookingRequestCommand command, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(command, cancellationToken));
|
||||
|
||||
[HttpPost("[action]/{id}")]
|
||||
[ProducesOkApiResponseType<BookingRequestDto>]
|
||||
public async Task<IActionResult> Cancel(long id, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new CancelBookingRequestCommand(id), cancellationToken));
|
||||
|
||||
[HttpPost("[action]/{id}")]
|
||||
[ProducesOkApiResponseType<BookingRequestDto>]
|
||||
public async Task<IActionResult> Accept(long id, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new AcceptBookingRequestCommand(id), cancellationToken));
|
||||
|
||||
[HttpPost("[action]/{id}")]
|
||||
[ProducesOkApiResponseType<BookingRequestDto>]
|
||||
public async Task<IActionResult> Reject(long id, RejectBookingRequestCommand command, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
|
||||
|
||||
[HttpGet("[action]")]
|
||||
[ProducesOkApiResponseType<PagedResult<BookingRequestListItemDto>>]
|
||||
public async Task<IActionResult> List([FromQuery] ListBookingRequestsQuery query, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(query, cancellationToken));
|
||||
|
||||
[HttpGet("[action]/{id}")]
|
||||
[ProducesOkApiResponseType<BookingRequestDto>]
|
||||
public async Task<IActionResult> Get(long id, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new GetBookingRequestQuery(id), cancellationToken));
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
|
||||
namespace Baya.Application.Contracts.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// The pre-payment <c>booking_requests</c> aggregate. Writes load tracked, tenancy-scoped rows (the caller's
|
||||
/// customer/nurse id) so an illegal cross-party access is a clean not-found, never a leak; reads project to
|
||||
/// role-scoped DTOs. The expiry sweep selects stale rows through the covering indexes in bounded batches.
|
||||
/// No money and no <c>bookings</c> row exist anywhere here.
|
||||
/// </summary>
|
||||
public interface IBookingRequestRepository
|
||||
{
|
||||
Task AddAsync(BookingRequest request, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Tracked, customer-owned lookup for cancel. NULL if not owned/absent (existence not leaked).</summary>
|
||||
Task<BookingRequest?> GetTrackedForCustomerAsync(long id, long customerId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Tracked, nurse-assigned lookup for accept/reject, with the <c>Customer</c> navigation loaded
|
||||
/// so the handler can notify the customer's user id. NULL if not the caller's/absent.</summary>
|
||||
Task<BookingRequest?> GetTrackedForNurseAsync(long id, long nurseId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Tracked <c>pending_nurse_response</c> rows whose response deadline has passed (with
|
||||
/// <c>Customer</c> loaded), capped at <paramref name="batchSize"/> — one bounded page of the expiry sweep.</summary>
|
||||
Task<IReadOnlyList<BookingRequest>> GetStalePendingAsync(DateTime now, int batchSize, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Tracked <c>accepted_awaiting_payment</c> rows whose payment window has lapsed (with
|
||||
/// <c>Customer</c> loaded), capped at <paramref name="batchSize"/>.</summary>
|
||||
Task<IReadOnlyList<BookingRequest>> GetStaleAcceptedAsync(DateTime now, int batchSize, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The customer's own inbox — counterparty is the nurse (name + rating), actionable first.</summary>
|
||||
Task<PagedResult<BookingRequestListItemDto>> ListForCustomerAsync(long customerId, string? status, int page, int pageSize, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The nurse's inbox — counterparty is the patient; exposes stage-1 <c>customer_notes</c> only.</summary>
|
||||
Task<PagedResult<BookingRequestListItemDto>> ListForNurseAsync(long nurseId, string? status, int page, int pageSize, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Role-agnostic detail projection (carries both party ids for authorization + the full address
|
||||
/// for the customer/admin path). NULL when absent.</summary>
|
||||
Task<BookingRequestDetailProjection?> GetDetailAsync(long id, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -26,4 +26,9 @@ public interface INurseProfileRepository
|
||||
/// <summary>The nurse's <c>nurse_profiles.id</c> + decrypted national id — what the bank-account
|
||||
/// ownership inquiry needs. NULL when the user has no nurse profile yet.</summary>
|
||||
Task<NurseIdentityContext?> GetIdentityContextByUserIdAsync(int userId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The target nurse's booking-relevant facts (user id, gender, verified/accepting gates) in one
|
||||
/// read — what a booking-request create needs to notify the nurse and run the bookability + same-gender
|
||||
/// checks. NULL when no such nurse profile exists.</summary>
|
||||
Task<NurseBookingContext?> GetBookingContextByIdAsync(long nurseProfileId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ public interface IUnitOfWork
|
||||
public ICatalogRepository CatalogRepository { get; }
|
||||
public INurseServiceVariantRepository NurseServiceVariantRepository { get; }
|
||||
public IVerificationRepository VerificationRepository { get; }
|
||||
public IBookingRequestRepository BookingRequestRepository { get; }
|
||||
Task CommitAsync();
|
||||
ValueTask RollBackAsync();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Booking;
|
||||
|
||||
namespace Baya.Application.Features.Booking;
|
||||
|
||||
/// <summary>
|
||||
/// Maps the role-agnostic detail projection to the wire DTO. <paramref name="includeFullAddress"/> is the
|
||||
/// stage-1 disclosure switch: the owning customer (and admin) get the full decrypted address; the nurse
|
||||
/// gets only the coarse city/district location plus <c>customer_notes</c>, never the encrypted address line.
|
||||
/// </summary>
|
||||
internal static class BookingRequestMapper
|
||||
{
|
||||
public static BookingRequestDto ToDto(BookingRequestDetailProjection p, bool includeFullAddress)
|
||||
=> new(
|
||||
p.Id,
|
||||
p.Status,
|
||||
p.NurseId,
|
||||
p.NurseName,
|
||||
p.NurseRating,
|
||||
p.NurseTotalReviews,
|
||||
p.PatientId,
|
||||
p.PatientName,
|
||||
p.VariantId,
|
||||
p.VariantLabel,
|
||||
p.VariantPriceUnit,
|
||||
p.CustomerAddressId,
|
||||
p.AddressTitle,
|
||||
p.CityId,
|
||||
p.CityNameFa,
|
||||
p.CityNameEn,
|
||||
p.DistrictId,
|
||||
p.DistrictNameFa,
|
||||
p.DistrictNameEn,
|
||||
includeFullAddress ? p.AddressLine : null,
|
||||
includeFullAddress ? p.PostalCode : null,
|
||||
includeFullAddress ? p.RecipientName : null,
|
||||
includeFullAddress ? p.RecipientPhone : null,
|
||||
p.RequiredCaregiverGender,
|
||||
p.RequestedDate,
|
||||
p.RequestedTimeStart,
|
||||
p.RequestedTimeEnd,
|
||||
p.CustomerNotes,
|
||||
p.NurseResponseDeadlineAt,
|
||||
p.PaymentDeadlineAt,
|
||||
p.NurseRejectionReason,
|
||||
p.CreatedAt);
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
#nullable enable
|
||||
using System.Text.Json;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Commands.AcceptBookingRequest;
|
||||
|
||||
internal sealed class AcceptBookingRequestCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IPlatformConfig platformConfig,
|
||||
IDateTimeProvider dateTimeProvider,
|
||||
INotificationDispatcher notifications)
|
||||
: IRequestHandler<AcceptBookingRequestCommand, OperationResult<BookingRequestDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<BookingRequestDto>> Handle(AcceptBookingRequestCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<BookingRequestDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
|
||||
return OperationResult<BookingRequestDto>.ForbiddenResult("Only a nurse can accept a booking request.");
|
||||
|
||||
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (nurseId is not { } nid)
|
||||
return OperationResult<BookingRequestDto>.ForbiddenResult("No nurse profile exists yet.");
|
||||
|
||||
var bookingRequest = await unitOfWork.BookingRequestRepository.GetTrackedForNurseAsync(request.Id, nid, cancellationToken);
|
||||
if (bookingRequest is null)
|
||||
return OperationResult<BookingRequestDto>.NotFoundResult("Booking request not found.");
|
||||
|
||||
if (!bookingRequest.CanTransitionTo(BookingRequestStatus.AcceptedAwaitingPayment))
|
||||
return OperationResult<BookingRequestDto>.ConflictResult("This request can no longer be accepted.");
|
||||
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
|
||||
// Self-guard against an already-passed response deadline — the expiry sweep may not have run yet, so
|
||||
// the command must not trust that it has.
|
||||
if (bookingRequest.NurseResponseDeadlineAt <= now)
|
||||
return OperationResult<BookingRequestDto>.ConflictResult("The response deadline has passed; this request can no longer be accepted.");
|
||||
|
||||
// Frozen from config, never the literal 30.
|
||||
var paymentDeadlineMinutes = await platformConfig.GetConfig<int>("booking_payment_deadline_minutes", cancellationToken);
|
||||
bookingRequest.Accept(now.AddMinutes(paymentDeadlineMinutes));
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
await notifications.DispatchAsync(
|
||||
new Notification(
|
||||
bookingRequest.Customer.UserId,
|
||||
"booking_request_accepted",
|
||||
"Booking request accepted",
|
||||
"The nurse accepted your request. Pay within the payment window to confirm your booking.",
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
booking_request_id = bookingRequest.Id,
|
||||
payment_deadline_at = bookingRequest.PaymentDeadlineAt?.ToString("O")
|
||||
})),
|
||||
cancellationToken);
|
||||
|
||||
var detail = await unitOfWork.BookingRequestRepository.GetDetailAsync(bookingRequest.Id, cancellationToken);
|
||||
// Nurse view — stage-1 disclosure masks the encrypted full address.
|
||||
return OperationResult<BookingRequestDto>.SuccessResult(BookingRequestMapper.ToDto(detail!, includeFullAddress: false));
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Commands.AcceptBookingRequest;
|
||||
|
||||
/// <summary>The assigned nurse accepts a pending request, opening the config-driven 30-minute payment
|
||||
/// window. <c>Id</c> comes from the route. No booking and no money are created — accept only opens the
|
||||
/// window.</summary>
|
||||
public record AcceptBookingRequestCommand(long Id = 0) : IRequest<OperationResult<BookingRequestDto>>;
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Commands.CancelBookingRequest;
|
||||
|
||||
internal sealed class CancelBookingRequestCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<CancelBookingRequestCommand, OperationResult<BookingRequestDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<BookingRequestDto>> Handle(CancelBookingRequestCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<BookingRequestDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
|
||||
return OperationResult<BookingRequestDto>.ForbiddenResult("Only a customer can cancel a booking request.");
|
||||
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (customerId is not { } cid)
|
||||
return OperationResult<BookingRequestDto>.ForbiddenResult("No customer profile exists yet.");
|
||||
|
||||
var bookingRequest = await unitOfWork.BookingRequestRepository.GetTrackedForCustomerAsync(request.Id, cid, cancellationToken);
|
||||
if (bookingRequest is null)
|
||||
return OperationResult<BookingRequestDto>.NotFoundResult("Booking request not found.");
|
||||
|
||||
if (!bookingRequest.CanTransitionTo(BookingRequestStatus.CancelledByCustomer))
|
||||
return OperationResult<BookingRequestDto>.ConflictResult("This request can no longer be cancelled.");
|
||||
|
||||
bookingRequest.CancelByCustomer();
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
var detail = await unitOfWork.BookingRequestRepository.GetDetailAsync(bookingRequest.Id, cancellationToken);
|
||||
// Owning customer — full address.
|
||||
return OperationResult<BookingRequestDto>.SuccessResult(BookingRequestMapper.ToDto(detail!, includeFullAddress: true));
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Commands.CancelBookingRequest;
|
||||
|
||||
/// <summary>The customer withdraws a request that is still pending or accepted-awaiting-payment (before they
|
||||
/// pay). <c>Id</c> comes from the route. This is a request cancellation only — a booking cancellation with
|
||||
/// refund tiers is b9+/DEFERRED.</summary>
|
||||
public record CancelBookingRequestCommand(long Id = 0) : IRequest<OperationResult<BookingRequestDto>>;
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
#nullable enable
|
||||
using System.Text.Json;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Commands.CreateBookingRequest;
|
||||
|
||||
internal sealed class CreateBookingRequestCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IPlatformConfig platformConfig,
|
||||
IDateTimeProvider dateTimeProvider,
|
||||
INotificationDispatcher notifications)
|
||||
: IRequestHandler<CreateBookingRequestCommand, OperationResult<BookingRequestDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<BookingRequestDto>> Handle(CreateBookingRequestCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<BookingRequestDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
|
||||
return OperationResult<BookingRequestDto>.ForbiddenResult("Only a customer can create a booking request.");
|
||||
|
||||
// Tenancy anchor: the customer id is resolved from the caller, never trusted from the body.
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (customerId is not { } cid)
|
||||
return OperationResult<BookingRequestDto>.FailureResult("No customer profile exists yet. Create your profile first.");
|
||||
|
||||
// Tenancy invariant: patient + address must belong to the caller; the variant must belong to the
|
||||
// requested nurse. A mismatch is a clean not-found — never a 500, never a leak of another party's row.
|
||||
var patient = await unitOfWork.PatientRepository.GetOwnedAsync(request.PatientId, cid, cancellationToken);
|
||||
if (patient is null)
|
||||
return OperationResult<BookingRequestDto>.NotFoundResult("Patient not found.");
|
||||
|
||||
var address = await unitOfWork.CustomerAddressRepository.GetOwnedAsync(request.CustomerAddressId, cid, cancellationToken);
|
||||
if (address is null)
|
||||
return OperationResult<BookingRequestDto>.NotFoundResult("Address not found.");
|
||||
|
||||
var variant = await unitOfWork.NurseServiceVariantRepository.GetOwnedAsync(request.VariantId, request.NurseId, cancellationToken);
|
||||
if (variant is null)
|
||||
return OperationResult<BookingRequestDto>.NotFoundResult("Service variant not found for this nurse.");
|
||||
|
||||
if (!variant.IsActive)
|
||||
return OperationResult<BookingRequestDto>.FailureResult(nameof(request.VariantId), "This service variant is not currently offered.");
|
||||
|
||||
var nurse = await unitOfWork.NurseProfileRepository.GetBookingContextByIdAsync(request.NurseId, cancellationToken);
|
||||
if (nurse is null)
|
||||
return OperationResult<BookingRequestDto>.NotFoundResult("Nurse not found.");
|
||||
|
||||
if (!nurse.IsVerified || !nurse.IsAcceptingBookings)
|
||||
return OperationResult<BookingRequestDto>.FailureResult(nameof(request.NurseId), "This nurse is not currently accepting bookings.");
|
||||
|
||||
// Same-gender care is decisive for bodily care — a male/female requirement must match the nurse's
|
||||
// gender at request time; "any" matches either. Never defaulted, never advisory.
|
||||
if (!CaregiverGender.Matches(request.RequiredCaregiverGender, nurse.Gender ?? string.Empty))
|
||||
return OperationResult<BookingRequestDto>.FailureResult(
|
||||
nameof(request.RequiredCaregiverGender),
|
||||
$"This nurse's gender does not match the required caregiver gender '{request.RequiredCaregiverGender}'.");
|
||||
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
if (request.RequestedDate < DateOnly.FromDateTime(now))
|
||||
return OperationResult<BookingRequestDto>.FailureResult(nameof(request.RequestedDate), "The requested date cannot be in the past.");
|
||||
|
||||
// Deadline is read from config and FROZEN as an absolute timestamp so a later config change can never
|
||||
// move an existing request's deadline.
|
||||
var responseDeadlineHours = await platformConfig.GetConfig<int>("nurse_response_deadline_hours", cancellationToken);
|
||||
|
||||
var bookingRequest = new BookingRequest
|
||||
{
|
||||
CustomerId = cid,
|
||||
NurseId = request.NurseId,
|
||||
PatientId = request.PatientId,
|
||||
VariantId = request.VariantId,
|
||||
CustomerAddressId = request.CustomerAddressId,
|
||||
RequiredCaregiverGender = request.RequiredCaregiverGender,
|
||||
RequestedDate = request.RequestedDate,
|
||||
RequestedTimeStart = request.RequestedTimeStart,
|
||||
RequestedTimeEnd = request.RequestedTimeEnd,
|
||||
CustomerNotes = string.IsNullOrWhiteSpace(request.CustomerNotes) ? null : request.CustomerNotes.Trim(),
|
||||
NurseResponseDeadlineAt = now.AddHours(responseDeadlineHours)
|
||||
};
|
||||
|
||||
await unitOfWork.BookingRequestRepository.AddAsync(bookingRequest, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
// The dispatcher self-commits its own row — invoke it only AFTER the request is persisted. Stage-1
|
||||
// disclosure: the payload carries the patient display name + date, nothing more.
|
||||
await notifications.DispatchAsync(
|
||||
new Notification(
|
||||
nurse.UserId,
|
||||
"booking_request_received",
|
||||
"New booking request",
|
||||
$"A new booking request from a customer awaits your response.",
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
booking_request_id = bookingRequest.Id,
|
||||
patient_display_name = patient.DisplayName,
|
||||
requested_date = request.RequestedDate.ToString("O")
|
||||
})),
|
||||
cancellationToken);
|
||||
|
||||
var detail = await unitOfWork.BookingRequestRepository.GetDetailAsync(bookingRequest.Id, cancellationToken);
|
||||
// The creator is the owning customer, so they see their own full address.
|
||||
return OperationResult<BookingRequestDto>.SuccessResult(BookingRequestMapper.ToDto(detail!, includeFullAddress: true));
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Commands.CreateBookingRequest;
|
||||
|
||||
public sealed class CreateBookingRequestCommandValidator : AbstractValidator<CreateBookingRequestCommand>
|
||||
{
|
||||
public CreateBookingRequestCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.NurseId).GreaterThan(0);
|
||||
RuleFor(x => x.VariantId).GreaterThan(0);
|
||||
RuleFor(x => x.PatientId).GreaterThan(0);
|
||||
RuleFor(x => x.CustomerAddressId).GreaterThan(0);
|
||||
|
||||
RuleFor(x => x.RequestedTimeEnd)
|
||||
.GreaterThan(x => x.RequestedTimeStart)
|
||||
.WithMessage("requested_time_end must be after requested_time_start.");
|
||||
|
||||
// Gender is load-bearing — it must be supplied and in the closed set; never silently defaulted.
|
||||
RuleFor(x => x.RequiredCaregiverGender)
|
||||
.NotEmpty()
|
||||
.Must(CaregiverGender.IsValid)
|
||||
.WithMessage("required_caregiver_gender must be one of: male, female, any.");
|
||||
|
||||
RuleFor(x => x.CustomerNotes).MaximumLength(1000);
|
||||
|
||||
// requested_date "not in the past" needs the injected clock, so it is enforced in the handler.
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Commands.CreateBookingRequest;
|
||||
|
||||
/// <summary>
|
||||
/// A customer requests a specific nurse for a patient, service variant, address, date and time, with a
|
||||
/// required caregiver gender and optional stage-1 <c>customer_notes</c>. The customer is derived from the
|
||||
/// caller, never the body. The handler enforces the tenancy invariant (patient + address ∈ caller,
|
||||
/// variant ∈ nurse), the same-gender match, bookability, and freezes the response deadline from config.
|
||||
/// No money and no booking are created — only a <c>pending_nurse_response</c> request.
|
||||
/// </summary>
|
||||
public record CreateBookingRequestCommand(
|
||||
long NurseId,
|
||||
long VariantId,
|
||||
long PatientId,
|
||||
long CustomerAddressId,
|
||||
DateOnly RequestedDate,
|
||||
TimeOnly RequestedTimeStart,
|
||||
TimeOnly RequestedTimeEnd,
|
||||
string RequiredCaregiverGender,
|
||||
string? CustomerNotes) : IRequest<OperationResult<BookingRequestDto>>;
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
#nullable enable
|
||||
using System.Text.Json;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Commands.ExpireBookingRequests;
|
||||
|
||||
internal sealed class ExpireBookingRequestsCommandHandler(
|
||||
IUnitOfWork unitOfWork,
|
||||
IDateTimeProvider dateTimeProvider,
|
||||
INotificationDispatcher notifications)
|
||||
: IRequestHandler<ExpireBookingRequestsCommand, OperationResult<ExpireBookingRequestsResult>>
|
||||
{
|
||||
private const int BatchSize = 100;
|
||||
|
||||
public async ValueTask<OperationResult<ExpireBookingRequestsResult>> Handle(ExpireBookingRequestsCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var expiredNoResponse = await SweepAsync(
|
||||
(now, ct) => unitOfWork.BookingRequestRepository.GetStalePendingAsync(now, BatchSize, ct),
|
||||
BookingRequestStatus.ExpiredNoResponse,
|
||||
r => r.ExpireNoResponse(),
|
||||
"booking_request_expired_no_response",
|
||||
"Booking request expired",
|
||||
"No nurse responded to your request in time. Please try another nurse.",
|
||||
cancellationToken);
|
||||
|
||||
var paymentExpired = await SweepAsync(
|
||||
(now, ct) => unitOfWork.BookingRequestRepository.GetStaleAcceptedAsync(now, BatchSize, ct),
|
||||
BookingRequestStatus.PaymentDeadlineExpired,
|
||||
r => r.ExpirePaymentWindow(),
|
||||
"booking_request_payment_window_expired",
|
||||
"Payment window expired",
|
||||
"The payment window for your accepted request lapsed. Please request again to book.",
|
||||
cancellationToken);
|
||||
|
||||
return OperationResult<ExpireBookingRequestsResult>.SuccessResult(
|
||||
new ExpireBookingRequestsResult(expiredNoResponse, paymentExpired));
|
||||
}
|
||||
|
||||
private async Task<int> SweepAsync(
|
||||
Func<DateTime, CancellationToken, Task<IReadOnlyList<BookingRequest>>> loadStale,
|
||||
string targetStatus,
|
||||
Action<BookingRequest> transition,
|
||||
string notificationType,
|
||||
string title,
|
||||
string body,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var total = 0;
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
var batch = await loadStale(now, cancellationToken);
|
||||
if (batch.Count == 0)
|
||||
break;
|
||||
|
||||
var moved = new List<BookingRequest>(batch.Count);
|
||||
foreach (var row in batch)
|
||||
{
|
||||
// Re-entrant guard: a row a racing accept/cancel already moved out of the expected state is
|
||||
// skipped rather than clobbered.
|
||||
if (!row.CanTransitionTo(targetStatus))
|
||||
continue;
|
||||
|
||||
transition(row);
|
||||
moved.Add(row);
|
||||
}
|
||||
|
||||
if (moved.Count == 0)
|
||||
break;
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
foreach (var row in moved)
|
||||
await notifications.DispatchAsync(
|
||||
new Notification(
|
||||
row.Customer.UserId,
|
||||
notificationType,
|
||||
title,
|
||||
body,
|
||||
JsonSerializer.Serialize(new { booking_request_id = row.Id })),
|
||||
cancellationToken);
|
||||
|
||||
total += moved.Count;
|
||||
|
||||
// A short page means the stale set is drained; the next page would be empty.
|
||||
if (batch.Count < BatchSize)
|
||||
break;
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Commands.ExpireBookingRequests;
|
||||
|
||||
/// <summary>
|
||||
/// Transitions stale requests: <c>pending_nurse_response → expired_no_response</c> once the response
|
||||
/// deadline passes, and <c>accepted_awaiting_payment → payment_deadline_expired</c> once the payment window
|
||||
/// lapses. Runs on an interval via a hosted <c>BackgroundService</c> and is also an admin manual trigger.
|
||||
/// Bounded, paginated, time-injected, and idempotent — a row a concurrent action already moved is simply
|
||||
/// not reloaded (the status predicate is the guard).
|
||||
/// </summary>
|
||||
public record ExpireBookingRequestsCommand : IRequest<OperationResult<ExpireBookingRequestsResult>>;
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
#nullable enable
|
||||
using System.Text.Json;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Commands.RejectBookingRequest;
|
||||
|
||||
internal sealed class RejectBookingRequestCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
INotificationDispatcher notifications)
|
||||
: IRequestHandler<RejectBookingRequestCommand, OperationResult<BookingRequestDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<BookingRequestDto>> Handle(RejectBookingRequestCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<BookingRequestDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
|
||||
return OperationResult<BookingRequestDto>.ForbiddenResult("Only a nurse can reject a booking request.");
|
||||
|
||||
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (nurseId is not { } nid)
|
||||
return OperationResult<BookingRequestDto>.ForbiddenResult("No nurse profile exists yet.");
|
||||
|
||||
var bookingRequest = await unitOfWork.BookingRequestRepository.GetTrackedForNurseAsync(request.Id, nid, cancellationToken);
|
||||
if (bookingRequest is null)
|
||||
return OperationResult<BookingRequestDto>.NotFoundResult("Booking request not found.");
|
||||
|
||||
if (!bookingRequest.CanTransitionTo(BookingRequestStatus.RejectedByNurse))
|
||||
return OperationResult<BookingRequestDto>.ConflictResult("This request can no longer be rejected.");
|
||||
|
||||
bookingRequest.Reject(request.Reason.Trim());
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
await notifications.DispatchAsync(
|
||||
new Notification(
|
||||
bookingRequest.Customer.UserId,
|
||||
"booking_request_rejected",
|
||||
"Booking request declined",
|
||||
"The nurse declined your request.",
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
booking_request_id = bookingRequest.Id,
|
||||
reason = bookingRequest.NurseRejectionReason
|
||||
})),
|
||||
cancellationToken);
|
||||
|
||||
var detail = await unitOfWork.BookingRequestRepository.GetDetailAsync(bookingRequest.Id, cancellationToken);
|
||||
return OperationResult<BookingRequestDto>.SuccessResult(BookingRequestMapper.ToDto(detail!, includeFullAddress: false));
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Commands.RejectBookingRequest;
|
||||
|
||||
public sealed class RejectBookingRequestCommandValidator : AbstractValidator<RejectBookingRequestCommand>
|
||||
{
|
||||
public RejectBookingRequestCommandValidator()
|
||||
{
|
||||
// Id is route-supplied — not validated here (see FluentValidation activation note in server CLAUDE.md).
|
||||
RuleFor(x => x.Reason)
|
||||
.NotEmpty()
|
||||
.MaximumLength(500);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Commands.RejectBookingRequest;
|
||||
|
||||
/// <summary>The assigned nurse declines a pending request with a required reason. <c>Id</c> comes from the
|
||||
/// route; only <c>Reason</c> is body input.</summary>
|
||||
public record RejectBookingRequestCommand(string Reason, long Id = 0) : IRequest<OperationResult<BookingRequestDto>>;
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Queries.GetBookingRequest;
|
||||
|
||||
internal sealed class GetBookingRequestQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<GetBookingRequestQuery, OperationResult<BookingRequestDto>>
|
||||
{
|
||||
private static readonly string[] AdminRoles =
|
||||
[RoleNames.Admin, RoleNames.SuperAdmin, RoleNames.Support, RoleNames.Finance, RoleNames.Moderation];
|
||||
|
||||
public async ValueTask<OperationResult<BookingRequestDto>> Handle(GetBookingRequestQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<BookingRequestDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var detail = await unitOfWork.BookingRequestRepository.GetDetailAsync(request.Id, cancellationToken);
|
||||
if (detail is null)
|
||||
return OperationResult<BookingRequestDto>.NotFoundResult("Booking request not found.");
|
||||
|
||||
var isAdmin = currentUser.Roles?.Any(AdminRoles.Contains) == true;
|
||||
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (customerId == detail.CustomerId)
|
||||
return OperationResult<BookingRequestDto>.SuccessResult(BookingRequestMapper.ToDto(detail, includeFullAddress: true));
|
||||
|
||||
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
if (nurseId == detail.NurseId)
|
||||
return OperationResult<BookingRequestDto>.SuccessResult(BookingRequestMapper.ToDto(detail, includeFullAddress: false));
|
||||
|
||||
if (isAdmin)
|
||||
return OperationResult<BookingRequestDto>.SuccessResult(BookingRequestMapper.ToDto(detail, includeFullAddress: true));
|
||||
|
||||
// Neither party nor admin — do not leak that the request exists.
|
||||
return OperationResult<BookingRequestDto>.NotFoundResult("Booking request not found.");
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Queries.GetBookingRequest;
|
||||
|
||||
/// <summary>A single request, visible only to its customer (full address), its nurse (stage-1 masked
|
||||
/// address), or an admin. Any other caller gets a not-found — existence is never leaked.</summary>
|
||||
public record GetBookingRequestQuery(long Id) : IRequest<OperationResult<BookingRequestDto>>;
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Queries.ListBookingRequests;
|
||||
|
||||
internal sealed class ListBookingRequestsQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<ListBookingRequestsQuery, OperationResult<PagedResult<BookingRequestListItemDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<PagedResult<BookingRequestListItemDto>>> Handle(ListBookingRequestsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<PagedResult<BookingRequestListItemDto>>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
|
||||
|
||||
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||
|
||||
var asNurse = ResolveInboxRole(request.Role, nurseId, customerId);
|
||||
if (asNurse is null)
|
||||
{
|
||||
if (nurseId is null && customerId is null)
|
||||
return OperationResult<PagedResult<BookingRequestListItemDto>>.SuccessResult(
|
||||
new PagedResult<BookingRequestListItemDto>([], 0, page, pageSize));
|
||||
|
||||
return OperationResult<PagedResult<BookingRequestListItemDto>>.FailureResult(
|
||||
nameof(request.Role), "Specify role=customer or role=nurse — you hold both roles.");
|
||||
}
|
||||
|
||||
var result = asNurse.Value
|
||||
? await unitOfWork.BookingRequestRepository.ListForNurseAsync(nurseId!.Value, request.Status, page, pageSize, cancellationToken)
|
||||
: await unitOfWork.BookingRequestRepository.ListForCustomerAsync(customerId!.Value, request.Status, page, pageSize, cancellationToken);
|
||||
|
||||
return OperationResult<PagedResult<BookingRequestListItemDto>>.SuccessResult(result);
|
||||
}
|
||||
|
||||
// Returns true = nurse inbox, false = customer inbox, null = undecidable (no profile, or both without a
|
||||
// Role hint).
|
||||
private static bool? ResolveInboxRole(string? role, long? nurseId, long? customerId)
|
||||
{
|
||||
if (string.Equals(role, RoleNames.Nurse, StringComparison.OrdinalIgnoreCase))
|
||||
return nurseId is not null ? true : null;
|
||||
if (string.Equals(role, RoleNames.Customer, StringComparison.OrdinalIgnoreCase))
|
||||
return customerId is not null ? false : null;
|
||||
|
||||
return (nurseId, customerId) switch
|
||||
{
|
||||
(not null, null) => true,
|
||||
(null, not null) => false,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Booking.Queries.ListBookingRequests;
|
||||
|
||||
/// <summary>
|
||||
/// The role-scoped inbox: a customer sees their own requests, a nurse sees requests addressed to them.
|
||||
/// <c>Role</c> disambiguates a user who holds both roles (<c>customer</c>/<c>nurse</c>); when omitted it is
|
||||
/// inferred from which profile the caller has. Optional <c>Status</c> filter; actionable rows sort first.
|
||||
/// </summary>
|
||||
public record ListBookingRequestsQuery(
|
||||
string? Status = null,
|
||||
string? Role = null,
|
||||
int Page = 1,
|
||||
int PageSize = 50) : IRequest<OperationResult<PagedResult<BookingRequestListItemDto>>>;
|
||||
@@ -0,0 +1,44 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Models.Booking;
|
||||
|
||||
/// <summary>
|
||||
/// The repository's role-agnostic detail projection for a single request. It carries both parties' ids so
|
||||
/// the handler can authorize the caller and decide visibility, plus the full (decrypted) address fields so
|
||||
/// the handler can surface them to the owning customer/admin and <b>mask</b> them for the nurse. The
|
||||
/// handler maps this to the public <see cref="BookingRequestDto"/>; the encrypted fields never leave the
|
||||
/// customer/admin path.
|
||||
/// </summary>
|
||||
public record BookingRequestDetailProjection(
|
||||
long Id,
|
||||
string Status,
|
||||
long CustomerId,
|
||||
long NurseId,
|
||||
string NurseName,
|
||||
decimal NurseRating,
|
||||
int NurseTotalReviews,
|
||||
long PatientId,
|
||||
string PatientName,
|
||||
long VariantId,
|
||||
string VariantLabel,
|
||||
string VariantPriceUnit,
|
||||
long CustomerAddressId,
|
||||
string AddressTitle,
|
||||
long CityId,
|
||||
string CityNameFa,
|
||||
string CityNameEn,
|
||||
long? DistrictId,
|
||||
string? DistrictNameFa,
|
||||
string? DistrictNameEn,
|
||||
string? AddressLine,
|
||||
string? PostalCode,
|
||||
string? RecipientName,
|
||||
string? RecipientPhone,
|
||||
string? RequiredCaregiverGender,
|
||||
DateOnly RequestedDate,
|
||||
TimeOnly RequestedTimeStart,
|
||||
TimeOnly RequestedTimeEnd,
|
||||
string? CustomerNotes,
|
||||
DateTime NurseResponseDeadlineAt,
|
||||
DateTime? PaymentDeadlineAt,
|
||||
string? NurseRejectionReason,
|
||||
DateTimeOffset CreatedAt);
|
||||
@@ -0,0 +1,42 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Models.Booking;
|
||||
|
||||
/// <summary>
|
||||
/// The full single-request view returned by create/accept/reject/cancel and the detail query. The
|
||||
/// <b>nurse</b> view masks the encrypted full address (<see cref="AddressLine"/>/<see cref="PostalCode"/>/
|
||||
/// recipient) — stage-1 disclosure surfaces only <see cref="CustomerNotes"/> plus a coarse city/district
|
||||
/// location. The <b>customer</b> (and admin) view returns their own full address.
|
||||
/// </summary>
|
||||
public record BookingRequestDto(
|
||||
long Id,
|
||||
string Status,
|
||||
long NurseId,
|
||||
string NurseName,
|
||||
decimal NurseRating,
|
||||
int NurseTotalReviews,
|
||||
long PatientId,
|
||||
string PatientName,
|
||||
long VariantId,
|
||||
string VariantLabel,
|
||||
string VariantPriceUnit,
|
||||
long CustomerAddressId,
|
||||
string AddressTitle,
|
||||
long CityId,
|
||||
string CityNameFa,
|
||||
string CityNameEn,
|
||||
long? DistrictId,
|
||||
string? DistrictNameFa,
|
||||
string? DistrictNameEn,
|
||||
string? AddressLine,
|
||||
string? PostalCode,
|
||||
string? RecipientName,
|
||||
string? RecipientPhone,
|
||||
string? RequiredCaregiverGender,
|
||||
DateOnly RequestedDate,
|
||||
TimeOnly RequestedTimeStart,
|
||||
TimeOnly RequestedTimeEnd,
|
||||
string? CustomerNotes,
|
||||
DateTime NurseResponseDeadlineAt,
|
||||
DateTime? PaymentDeadlineAt,
|
||||
string? NurseRejectionReason,
|
||||
DateTimeOffset CreatedAt);
|
||||
@@ -0,0 +1,21 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Models.Booking;
|
||||
|
||||
/// <summary>
|
||||
/// One row in the role-scoped inbox. <see cref="CounterpartyName"/> is the nurse's name in the customer
|
||||
/// inbox and the patient's name in the nurse inbox; <see cref="NurseRating"/> is populated only in the
|
||||
/// customer inbox, and <see cref="CustomerNotes"/> (stage-1 disclosure) only in the nurse inbox. No
|
||||
/// encrypted/clinical field is ever projected here.
|
||||
/// </summary>
|
||||
public record BookingRequestListItemDto(
|
||||
long Id,
|
||||
string Status,
|
||||
string CounterpartyName,
|
||||
decimal? NurseRating,
|
||||
string? RequiredCaregiverGender,
|
||||
DateOnly RequestedDate,
|
||||
TimeOnly RequestedTimeStart,
|
||||
TimeOnly RequestedTimeEnd,
|
||||
DateTime NurseResponseDeadlineAt,
|
||||
DateTime? PaymentDeadlineAt,
|
||||
string? CustomerNotes);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace Baya.Application.Models.Booking;
|
||||
|
||||
/// <summary>How many stale requests each expiry sweep moved — surfaced by the admin manual trigger and used
|
||||
/// by the sweep's logging. Running the sweep again with no stale rows returns zeros (idempotent).</summary>
|
||||
public record ExpireBookingRequestsResult(int ExpiredNoResponse, int PaymentDeadlineExpired);
|
||||
@@ -0,0 +1,13 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Models.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// The facts a booking-request create needs about the target nurse in one read: their <see cref="UserId"/>
|
||||
/// (for the request-received notification), their <see cref="Gender"/> (for the same-gender match), and the
|
||||
/// two bookability gates (<see cref="IsVerified"/>, <see cref="IsAcceptingBookings"/>).
|
||||
/// </summary>
|
||||
public record NurseBookingContext(
|
||||
int UserId,
|
||||
string? Gender,
|
||||
bool IsVerified,
|
||||
bool IsAcceptingBookings);
|
||||
@@ -0,0 +1,99 @@
|
||||
#nullable enable
|
||||
using Baya.Domain.Common;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
|
||||
namespace Baya.Domain.Entities.Booking;
|
||||
|
||||
/// <summary>
|
||||
/// A customer's <b>pre-payment intent</b> for a specific nurse, patient, service variant, address, date and
|
||||
/// time. It is deliberately money-free and separate from <c>bookings</c> (b9): a request can be rejected,
|
||||
/// time out, or have its payment window lapse without a booking ever existing. The nurse sees only the
|
||||
/// limited, unencrypted <see cref="CustomerNotes"/> before accepting — stage 1 of the two-stage clinical
|
||||
/// disclosure boundary (the full encrypted care instructions are b9's, post-confirmation).
|
||||
/// <para>
|
||||
/// Both deadlines are <b>computed once from config and frozen</b> on the row, so a later config change can
|
||||
/// never move an existing request's deadlines. Status changes only through the forward-only
|
||||
/// <see cref="BookingRequestTransitions"/> guard.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class BookingRequest : BaseEntity<long>
|
||||
{
|
||||
public long CustomerId { get; set; }
|
||||
public CustomerProfile Customer { get; set; } = null!;
|
||||
|
||||
public long NurseId { get; set; }
|
||||
public NurseProfile Nurse { get; set; } = null!;
|
||||
|
||||
public long PatientId { get; set; }
|
||||
public Patient Patient { get; set; } = null!;
|
||||
|
||||
public long VariantId { get; set; }
|
||||
public NurseServiceVariant Variant { get; set; } = null!;
|
||||
|
||||
public long CustomerAddressId { get; set; }
|
||||
public CustomerAddress CustomerAddress { get; set; } = null!;
|
||||
|
||||
/// <summary>Closed code set — see <see cref="CaregiverGender"/>. Matched against the nurse's gender at
|
||||
/// request time; a first-class filter, never a soft preference.</summary>
|
||||
public string? RequiredCaregiverGender { get; set; }
|
||||
|
||||
public DateOnly RequestedDate { get; set; }
|
||||
public TimeOnly RequestedTimeStart { get; set; }
|
||||
public TimeOnly RequestedTimeEnd { get; set; }
|
||||
|
||||
/// <summary><b>Unencrypted, request-stage only</b> — the ONLY clinical context the nurse sees before
|
||||
/// accepting (Principle 6, stage 1). Never routed through the field encryptor; deliberately limited.</summary>
|
||||
public string? CustomerNotes { get; set; }
|
||||
|
||||
/// <summary>Guarded — mutated only through the transition methods so every write goes through the
|
||||
/// forward-only status machine.</summary>
|
||||
public string Status { get; private set; } = BookingRequestStatus.PendingNurseResponse;
|
||||
|
||||
/// <summary>UTC <c>datetime2</c>, frozen at create time (<c>now + nurse_response_deadline_hours</c>),
|
||||
/// immune to later config changes. After it passes an unanswered request auto-expires. Stored as
|
||||
/// <see cref="DateTime"/> (not <see cref="DateTimeOffset"/>) because it is compared/sorted in queries and
|
||||
/// the SQLite test provider cannot translate <c>DateTimeOffset</c> operators.</summary>
|
||||
public DateTime NurseResponseDeadlineAt { get; set; }
|
||||
|
||||
/// <summary>Null until accept; then frozen to <c>now + booking_payment_deadline_minutes</c> (= 30). UTC.</summary>
|
||||
public DateTime? PaymentDeadlineAt { get; private set; }
|
||||
|
||||
public string? NurseRejectionReason { get; private set; }
|
||||
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
|
||||
public bool CanTransitionTo(string target) => BookingRequestTransitions.CanTransition(Status, target);
|
||||
|
||||
/// <summary>Nurse accepts — opens the (already-computed) 30-minute payment window. No booking, no money.</summary>
|
||||
public void Accept(DateTime paymentDeadlineAt)
|
||||
{
|
||||
Transition(BookingRequestStatus.AcceptedAwaitingPayment);
|
||||
PaymentDeadlineAt = paymentDeadlineAt;
|
||||
}
|
||||
|
||||
public void Reject(string reason)
|
||||
{
|
||||
Transition(BookingRequestStatus.RejectedByNurse);
|
||||
NurseRejectionReason = reason;
|
||||
}
|
||||
|
||||
public void CancelByCustomer() => Transition(BookingRequestStatus.CancelledByCustomer);
|
||||
|
||||
public void ExpireNoResponse() => Transition(BookingRequestStatus.ExpiredNoResponse);
|
||||
|
||||
public void ExpirePaymentWindow() => Transition(BookingRequestStatus.PaymentDeadlineExpired);
|
||||
|
||||
/// <summary>Marks the request converted — called only by b9 when it creates the <c>bookings</c> row.</summary>
|
||||
public void MarkConverted() => Transition(BookingRequestStatus.Converted);
|
||||
|
||||
// Callers pre-check with CanTransitionTo and return a clean 409; reaching an illegal edge here is a
|
||||
// programming error, so it fails fast rather than silently overwriting a terminal state.
|
||||
private void Transition(string target)
|
||||
{
|
||||
if (!BookingRequestTransitions.CanTransition(Status, target))
|
||||
throw new InvalidOperationException($"Illegal booking-request transition {Status} → {target}.");
|
||||
|
||||
Status = target;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace Baya.Domain.Entities.Booking;
|
||||
|
||||
/// <summary>
|
||||
/// The closed status vocabulary of a <see cref="BookingRequest"/> — the <b>pre-payment</b> half of the
|
||||
/// engagement lifecycle. Persisted as these stable snake_case codes (never a C# enum member name) so the
|
||||
/// DB and the wire read the same. The forward-only edges live in <see cref="BookingRequestTransitions"/>.
|
||||
/// </summary>
|
||||
public static class BookingRequestStatus
|
||||
{
|
||||
/// <summary>Awaiting the nurse's accept/reject before <c>nurse_response_deadline_at</c>.</summary>
|
||||
public const string PendingNurseResponse = "pending_nurse_response";
|
||||
|
||||
/// <summary>Nurse accepted; the 30-minute <c>payment_deadline_at</c> window is open. No money yet.</summary>
|
||||
public const string AcceptedAwaitingPayment = "accepted_awaiting_payment";
|
||||
|
||||
/// <summary>A booking was created from this request (set by b9 on payment capture). Terminal.</summary>
|
||||
public const string Converted = "converted";
|
||||
|
||||
/// <summary>Nurse declined with a reason. Terminal.</summary>
|
||||
public const string RejectedByNurse = "rejected_by_nurse";
|
||||
|
||||
/// <summary>The nurse never responded before the deadline (set by the expiry sweep). Terminal.</summary>
|
||||
public const string ExpiredNoResponse = "expired_no_response";
|
||||
|
||||
/// <summary>The 30-minute payment window lapsed unpaid (set by the expiry sweep). Terminal.</summary>
|
||||
public const string PaymentDeadlineExpired = "payment_deadline_expired";
|
||||
|
||||
/// <summary>The customer withdrew the request before paying. Terminal.</summary>
|
||||
public const string CancelledByCustomer = "cancelled_by_customer";
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace Baya.Domain.Entities.Booking;
|
||||
|
||||
/// <summary>
|
||||
/// The forward-only status machine for <see cref="BookingRequest"/>. Every accept/reject/cancel/expire/
|
||||
/// convert edge is validated here, so an illegal transition is a clean conflict (never a silent overwrite),
|
||||
/// and the terminal states have no outgoing edge. b9 reuses this pattern for the <c>bookings</c> machine.
|
||||
/// </summary>
|
||||
public static class BookingRequestTransitions
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, IReadOnlyCollection<string>> Allowed =
|
||||
new Dictionary<string, IReadOnlyCollection<string>>
|
||||
{
|
||||
[BookingRequestStatus.PendingNurseResponse] =
|
||||
[
|
||||
BookingRequestStatus.AcceptedAwaitingPayment,
|
||||
BookingRequestStatus.RejectedByNurse,
|
||||
BookingRequestStatus.ExpiredNoResponse,
|
||||
BookingRequestStatus.CancelledByCustomer
|
||||
],
|
||||
[BookingRequestStatus.AcceptedAwaitingPayment] =
|
||||
[
|
||||
BookingRequestStatus.Converted,
|
||||
BookingRequestStatus.PaymentDeadlineExpired,
|
||||
BookingRequestStatus.CancelledByCustomer
|
||||
],
|
||||
// Terminal states — no outgoing edges.
|
||||
[BookingRequestStatus.Converted] = [],
|
||||
[BookingRequestStatus.RejectedByNurse] = [],
|
||||
[BookingRequestStatus.ExpiredNoResponse] = [],
|
||||
[BookingRequestStatus.PaymentDeadlineExpired] = [],
|
||||
[BookingRequestStatus.CancelledByCustomer] = []
|
||||
};
|
||||
|
||||
public static bool CanTransition(string from, string to)
|
||||
=> Allowed.TryGetValue(from, out var targets) && targets.Contains(to);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Baya.Domain.Entities.Booking;
|
||||
|
||||
/// <summary>
|
||||
/// The closed code set for <c>required_caregiver_gender</c>. Same-gender bodily care is decisive in the
|
||||
/// Iranian context, so this is a first-class filter matched against the nurse's gender at request time —
|
||||
/// never a soft preference and never silently defaulted. <see cref="Any"/> matches either nurse gender.
|
||||
/// </summary>
|
||||
public static class CaregiverGender
|
||||
{
|
||||
public const string Male = "male";
|
||||
public const string Female = "female";
|
||||
public const string Any = "any";
|
||||
|
||||
public static bool IsValid(string value)
|
||||
=> value is Male or Female or Any;
|
||||
|
||||
/// <summary>
|
||||
/// True when a nurse of <paramref name="nurseGender"/> satisfies the required caregiver gender.
|
||||
/// <see cref="Any"/> always matches; <see cref="Male"/>/<see cref="Female"/> require an exact match.
|
||||
/// </summary>
|
||||
public static bool Matches(string required, string nurseGender)
|
||||
=> required == Any || string.Equals(required, nurseGender, System.StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Configuration.BookingConfig;
|
||||
|
||||
internal sealed class BookingRequestConfig : IEntityTypeConfiguration<BookingRequest>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<BookingRequest> builder)
|
||||
{
|
||||
builder.ToTable("BookingRequests", "booking");
|
||||
|
||||
// required_caregiver_gender is a closed code set (male/female/any); customer_notes is DELIBERATELY
|
||||
// unencrypted, limited stage-1 clinical context — never routed through the field encryptor.
|
||||
builder.Property(r => r.RequiredCaregiverGender).HasMaxLength(10);
|
||||
builder.Property(r => r.CustomerNotes).HasMaxLength(1000);
|
||||
builder.Property(r => r.Status).HasMaxLength(50).IsRequired();
|
||||
builder.Property(r => r.NurseRejectionReason).HasMaxLength(500);
|
||||
builder.Property(r => r.NurseResponseDeadlineAt).IsRequired();
|
||||
|
||||
// Inbox lists read on (party, status), actionable-first; the two deadline indexes let the expiry
|
||||
// sweep select stale rows through a covering index instead of scanning the table.
|
||||
builder.HasIndex(r => new { r.NurseId, r.Status });
|
||||
builder.HasIndex(r => new { r.CustomerId, r.Status });
|
||||
builder.HasIndex(r => new { r.Status, r.NurseResponseDeadlineAt });
|
||||
builder.HasIndex(r => new { r.Status, r.PaymentDeadlineAt });
|
||||
|
||||
builder.HasOne(r => r.Customer)
|
||||
.WithMany()
|
||||
.HasForeignKey(r => r.CustomerId)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasOne(r => r.Nurse)
|
||||
.WithMany()
|
||||
.HasForeignKey(r => r.NurseId)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasOne(r => r.Patient)
|
||||
.WithMany()
|
||||
.HasForeignKey(r => r.PatientId)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasOne(r => r.Variant)
|
||||
.WithMany()
|
||||
.HasForeignKey(r => r.VariantId)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasOne(r => r.CustomerAddress)
|
||||
.WithMany()
|
||||
.HasForeignKey(r => r.CustomerAddressId)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasQueryFilter(r => r.DeletedAt == null);
|
||||
}
|
||||
}
|
||||
+3660
File diff suppressed because it is too large
Load Diff
+135
@@ -0,0 +1,135 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class BookingRequests : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "booking");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BookingRequests",
|
||||
schema: "booking",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
CustomerId = table.Column<long>(type: "bigint", nullable: false),
|
||||
NurseId = table.Column<long>(type: "bigint", nullable: false),
|
||||
PatientId = table.Column<long>(type: "bigint", nullable: false),
|
||||
VariantId = table.Column<long>(type: "bigint", nullable: false),
|
||||
CustomerAddressId = table.Column<long>(type: "bigint", nullable: false),
|
||||
RequiredCaregiverGender = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: true),
|
||||
RequestedDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
RequestedTimeStart = table.Column<TimeOnly>(type: "time", nullable: false),
|
||||
RequestedTimeEnd = table.Column<TimeOnly>(type: "time", nullable: false),
|
||||
CustomerNotes = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
|
||||
Status = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
NurseResponseDeadlineAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
PaymentDeadlineAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
NurseRejectionReason = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
CreatedById = table.Column<int>(type: "int", nullable: true),
|
||||
ModifiedById = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_BookingRequests", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_BookingRequests_CustomerAddresses_CustomerAddressId",
|
||||
column: x => x.CustomerAddressId,
|
||||
principalSchema: "usr",
|
||||
principalTable: "CustomerAddresses",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_BookingRequests_CustomerProfiles_CustomerId",
|
||||
column: x => x.CustomerId,
|
||||
principalSchema: "usr",
|
||||
principalTable: "CustomerProfiles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_BookingRequests_NurseProfiles_NurseId",
|
||||
column: x => x.NurseId,
|
||||
principalSchema: "usr",
|
||||
principalTable: "NurseProfiles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_BookingRequests_NurseServiceVariants_VariantId",
|
||||
column: x => x.VariantId,
|
||||
principalSchema: "catalog",
|
||||
principalTable: "NurseServiceVariants",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_BookingRequests_Patients_PatientId",
|
||||
column: x => x.PatientId,
|
||||
principalSchema: "usr",
|
||||
principalTable: "Patients",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BookingRequests_CustomerAddressId",
|
||||
schema: "booking",
|
||||
table: "BookingRequests",
|
||||
column: "CustomerAddressId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BookingRequests_CustomerId_Status",
|
||||
schema: "booking",
|
||||
table: "BookingRequests",
|
||||
columns: new[] { "CustomerId", "Status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BookingRequests_NurseId_Status",
|
||||
schema: "booking",
|
||||
table: "BookingRequests",
|
||||
columns: new[] { "NurseId", "Status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BookingRequests_PatientId",
|
||||
schema: "booking",
|
||||
table: "BookingRequests",
|
||||
column: "PatientId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BookingRequests_Status_NurseResponseDeadlineAt",
|
||||
schema: "booking",
|
||||
table: "BookingRequests",
|
||||
columns: new[] { "Status", "NurseResponseDeadlineAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BookingRequests_Status_PaymentDeadlineAt",
|
||||
schema: "booking",
|
||||
table: "BookingRequests",
|
||||
columns: new[] { "Status", "PaymentDeadlineAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BookingRequests_VariantId",
|
||||
schema: "booking",
|
||||
table: "BookingRequests",
|
||||
column: "VariantId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "BookingRequests",
|
||||
schema: "booking");
|
||||
}
|
||||
}
|
||||
}
|
||||
+132
@@ -98,6 +98,95 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("AuditLogs", "ops");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingRequest", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("CreatedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<long>("CustomerAddressId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("CustomerId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("CustomerNotes")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<DateTimeOffset?>("ModifiedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<int?>("ModifiedById")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<long>("NurseId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("NurseRejectionReason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<DateTime>("NurseResponseDeadlineAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<long>("PatientId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTime?>("PaymentDeadlineAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateOnly>("RequestedDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<TimeOnly>("RequestedTimeEnd")
|
||||
.HasColumnType("time");
|
||||
|
||||
b.Property<TimeOnly>("RequestedTimeStart")
|
||||
.HasColumnType("time");
|
||||
|
||||
b.Property<string>("RequiredCaregiverGender")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("nvarchar(10)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<long>("VariantId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CustomerAddressId");
|
||||
|
||||
b.HasIndex("PatientId");
|
||||
|
||||
b.HasIndex("VariantId");
|
||||
|
||||
b.HasIndex("CustomerId", "Status");
|
||||
|
||||
b.HasIndex("NurseId", "Status");
|
||||
|
||||
b.HasIndex("Status", "NurseResponseDeadlineAt");
|
||||
|
||||
b.HasIndex("Status", "PaymentDeadlineAt");
|
||||
|
||||
b.ToTable("BookingRequests", "booking");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -3076,6 +3165,49 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
||||
.HasForeignKey("ActorUserId");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingRequest", b =>
|
||||
{
|
||||
b.HasOne("Baya.Domain.Entities.Identity.CustomerAddress", "CustomerAddress")
|
||||
.WithMany()
|
||||
.HasForeignKey("CustomerAddressId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer")
|
||||
.WithMany()
|
||||
.HasForeignKey("CustomerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse")
|
||||
.WithMany()
|
||||
.HasForeignKey("NurseId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Baya.Domain.Entities.Identity.Patient", "Patient")
|
||||
.WithMany()
|
||||
.HasForeignKey("PatientId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant")
|
||||
.WithMany()
|
||||
.HasForeignKey("VariantId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Customer");
|
||||
|
||||
b.Navigation("CustomerAddress");
|
||||
|
||||
b.Navigation("Nurse");
|
||||
|
||||
b.Navigation("Patient");
|
||||
|
||||
b.Navigation("Variant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b =>
|
||||
{
|
||||
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse")
|
||||
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Infrastructure.Persistence.Repositories.Common;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Repositories;
|
||||
|
||||
internal sealed class BookingRequestRepository : BaseAsyncRepository<BookingRequest>, IBookingRequestRepository
|
||||
{
|
||||
public BookingRequestRepository(ApplicationDbContext dbContext) : base(dbContext)
|
||||
{
|
||||
}
|
||||
|
||||
public Task AddAsync(BookingRequest request, CancellationToken cancellationToken)
|
||||
=> base.AddAsync(request);
|
||||
|
||||
public Task<BookingRequest?> GetTrackedForCustomerAsync(long id, long customerId, CancellationToken cancellationToken)
|
||||
=> Table.FirstOrDefaultAsync(r => r.Id == id && r.CustomerId == customerId, cancellationToken);
|
||||
|
||||
public Task<BookingRequest?> GetTrackedForNurseAsync(long id, long nurseId, CancellationToken cancellationToken)
|
||||
=> Table
|
||||
.Include(r => r.Customer)
|
||||
.FirstOrDefaultAsync(r => r.Id == id && r.NurseId == nurseId, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<BookingRequest>> GetStalePendingAsync(DateTime now, int batchSize, CancellationToken cancellationToken)
|
||||
=> await Table
|
||||
.Include(r => r.Customer)
|
||||
.Where(r => r.Status == BookingRequestStatus.PendingNurseResponse && r.NurseResponseDeadlineAt <= now)
|
||||
// Order by id (not the deadline): the whole batch is stale, order is irrelevant for correctness,
|
||||
// and DateTimeOffset is not sortable on the SQLite test provider.
|
||||
.OrderBy(r => r.Id)
|
||||
.Take(batchSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<BookingRequest>> GetStaleAcceptedAsync(DateTime now, int batchSize, CancellationToken cancellationToken)
|
||||
=> await Table
|
||||
.Include(r => r.Customer)
|
||||
.Where(r => r.Status == BookingRequestStatus.AcceptedAwaitingPayment
|
||||
&& r.PaymentDeadlineAt != null && r.PaymentDeadlineAt <= now)
|
||||
.OrderBy(r => r.Id)
|
||||
.Take(batchSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
public async Task<PagedResult<BookingRequestListItemDto>> ListForCustomerAsync(
|
||||
long customerId, string? status, int page, int pageSize, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = TableNoTracking.Where(r => r.CustomerId == customerId);
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
query = query.Where(r => r.Status == status);
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var rows = await OrderActionableFirst(query)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(r => new CustomerRow(
|
||||
r.Id,
|
||||
r.Status,
|
||||
r.Nurse.User.Name,
|
||||
r.Nurse.User.FamilyName,
|
||||
r.Nurse.AverageRating,
|
||||
r.RequiredCaregiverGender,
|
||||
r.RequestedDate,
|
||||
r.RequestedTimeStart,
|
||||
r.RequestedTimeEnd,
|
||||
r.NurseResponseDeadlineAt,
|
||||
r.PaymentDeadlineAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var items = rows
|
||||
.Select(r => new BookingRequestListItemDto(
|
||||
r.Id, r.Status, ComposeName(r.NurseName, r.NurseFamily), r.NurseRating,
|
||||
r.RequiredCaregiverGender, r.RequestedDate, r.RequestedTimeStart, r.RequestedTimeEnd,
|
||||
r.NurseResponseDeadlineAt, r.PaymentDeadlineAt, CustomerNotes: null))
|
||||
.ToList();
|
||||
|
||||
return new PagedResult<BookingRequestListItemDto>(items, total, page, pageSize);
|
||||
}
|
||||
|
||||
public async Task<PagedResult<BookingRequestListItemDto>> ListForNurseAsync(
|
||||
long nurseId, string? status, int page, int pageSize, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = TableNoTracking.Where(r => r.NurseId == nurseId);
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
query = query.Where(r => r.Status == status);
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var items = await OrderActionableFirst(query)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
// Nurse inbox: counterparty is the patient, and stage-1 customer_notes is the ONLY clinical
|
||||
// context surfaced. No encrypted/care field is projected here, ever.
|
||||
.Select(r => new BookingRequestListItemDto(
|
||||
r.Id,
|
||||
r.Status,
|
||||
r.Patient.DisplayName,
|
||||
null,
|
||||
r.RequiredCaregiverGender,
|
||||
r.RequestedDate,
|
||||
r.RequestedTimeStart,
|
||||
r.RequestedTimeEnd,
|
||||
r.NurseResponseDeadlineAt,
|
||||
r.PaymentDeadlineAt,
|
||||
r.CustomerNotes))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return new PagedResult<BookingRequestListItemDto>(items, total, page, pageSize);
|
||||
}
|
||||
|
||||
public async Task<BookingRequestDetailProjection?> GetDetailAsync(long id, CancellationToken cancellationToken)
|
||||
{
|
||||
var row = await TableNoTracking
|
||||
.Where(r => r.Id == id)
|
||||
.Select(r => new DetailRow(
|
||||
r.Id,
|
||||
r.Status,
|
||||
r.CustomerId,
|
||||
r.NurseId,
|
||||
r.Nurse.User.Name,
|
||||
r.Nurse.User.FamilyName,
|
||||
r.Nurse.AverageRating,
|
||||
r.Nurse.TotalReviews,
|
||||
r.PatientId,
|
||||
r.Patient.DisplayName,
|
||||
r.VariantId,
|
||||
r.Variant.DisplayName,
|
||||
r.Variant.PriceUnit,
|
||||
r.CustomerAddressId,
|
||||
r.CustomerAddress.Title,
|
||||
r.CustomerAddress.CityId,
|
||||
r.CustomerAddress.City.NameFa,
|
||||
r.CustomerAddress.City.NameEn,
|
||||
r.CustomerAddress.DistrictId,
|
||||
r.CustomerAddress.DistrictId == null ? null : r.CustomerAddress.District.NameFa,
|
||||
r.CustomerAddress.DistrictId == null ? null : r.CustomerAddress.District.NameEn,
|
||||
r.CustomerAddress.AddressLine,
|
||||
r.CustomerAddress.PostalCode,
|
||||
r.CustomerAddress.RecipientName,
|
||||
r.CustomerAddress.RecipientPhone,
|
||||
r.RequiredCaregiverGender,
|
||||
r.RequestedDate,
|
||||
r.RequestedTimeStart,
|
||||
r.RequestedTimeEnd,
|
||||
r.CustomerNotes,
|
||||
r.NurseResponseDeadlineAt,
|
||||
r.PaymentDeadlineAt,
|
||||
r.NurseRejectionReason,
|
||||
r.CreatedAt))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (row is null)
|
||||
return null;
|
||||
|
||||
return new BookingRequestDetailProjection(
|
||||
row.Id, row.Status, row.CustomerId, row.NurseId,
|
||||
ComposeName(row.NurseName, row.NurseFamily), row.NurseRating, row.NurseTotalReviews,
|
||||
row.PatientId, row.PatientName,
|
||||
row.VariantId, row.VariantLabel, row.VariantPriceUnit,
|
||||
row.CustomerAddressId, row.AddressTitle, row.CityId, row.CityNameFa, row.CityNameEn,
|
||||
row.DistrictId, row.DistrictNameFa, row.DistrictNameEn,
|
||||
row.AddressLine, row.PostalCode, row.RecipientName, row.RecipientPhone,
|
||||
row.RequiredCaregiverGender, row.RequestedDate, row.RequestedTimeStart, row.RequestedTimeEnd,
|
||||
row.CustomerNotes, row.NurseResponseDeadlineAt, row.PaymentDeadlineAt,
|
||||
row.NurseRejectionReason, row.CreatedAt);
|
||||
}
|
||||
|
||||
// Actionable (awaiting a party's action) rows float above terminal ones, then most-recent first. Recency
|
||||
// (id) rather than the deadline is the secondary key: for a given status the deadline tracks creation
|
||||
// time anyway, and DateTimeOffset is not sortable on the SQLite test provider.
|
||||
private static IQueryable<BookingRequest> OrderActionableFirst(IQueryable<BookingRequest> query)
|
||||
=> query
|
||||
.OrderByDescending(r => r.Status == BookingRequestStatus.PendingNurseResponse
|
||||
|| r.Status == BookingRequestStatus.AcceptedAwaitingPayment)
|
||||
.ThenByDescending(r => r.Id);
|
||||
|
||||
private static string ComposeName(string? name, string? familyName)
|
||||
=> string.Join(' ', new[] { name, familyName }.Where(s => !string.IsNullOrWhiteSpace(s))).Trim();
|
||||
|
||||
private sealed record CustomerRow(
|
||||
long Id, string Status, string? NurseName, string? NurseFamily, decimal NurseRating,
|
||||
string? RequiredCaregiverGender, DateOnly RequestedDate, TimeOnly RequestedTimeStart, TimeOnly RequestedTimeEnd,
|
||||
DateTime NurseResponseDeadlineAt, DateTime? PaymentDeadlineAt);
|
||||
|
||||
private sealed record DetailRow(
|
||||
long Id, string Status, long CustomerId, long NurseId,
|
||||
string? NurseName, string? NurseFamily, decimal NurseRating, int NurseTotalReviews,
|
||||
long PatientId, string PatientName,
|
||||
long VariantId, string VariantLabel, string VariantPriceUnit,
|
||||
long CustomerAddressId, string AddressTitle, long CityId, string CityNameFa, string CityNameEn,
|
||||
long? DistrictId, string? DistrictNameFa, string? DistrictNameEn,
|
||||
string? AddressLine, string? PostalCode, string? RecipientName, string? RecipientPhone,
|
||||
string? RequiredCaregiverGender, DateOnly RequestedDate, TimeOnly RequestedTimeStart, TimeOnly RequestedTimeEnd,
|
||||
string? CustomerNotes, DateTime NurseResponseDeadlineAt, DateTime? PaymentDeadlineAt,
|
||||
string? NurseRejectionReason, DateTimeOffset CreatedAt);
|
||||
}
|
||||
+2
@@ -19,6 +19,7 @@ public class UnitOfWork : IUnitOfWork
|
||||
public ICatalogRepository CatalogRepository { get; }
|
||||
public INurseServiceVariantRepository NurseServiceVariantRepository { get; }
|
||||
public IVerificationRepository VerificationRepository { get; }
|
||||
public IBookingRequestRepository BookingRequestRepository { get; }
|
||||
|
||||
public UnitOfWork(ApplicationDbContext db)
|
||||
{
|
||||
@@ -36,6 +37,7 @@ public class UnitOfWork : IUnitOfWork
|
||||
CatalogRepository = new CatalogRepository(_db);
|
||||
NurseServiceVariantRepository = new NurseServiceVariantRepository(_db);
|
||||
VerificationRepository = new VerificationRepository(_db);
|
||||
BookingRequestRepository = new BookingRequestRepository(_db);
|
||||
}
|
||||
|
||||
public Task CommitAsync()
|
||||
|
||||
+6
@@ -49,4 +49,10 @@ internal sealed class NurseProfileRepository : BaseAsyncRepository<NurseProfile>
|
||||
.Where(p => p.UserId == userId)
|
||||
.Select(p => new NurseIdentityContext(p.Id, p.User.NationalId))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
public Task<NurseBookingContext> GetBookingContextByIdAsync(long nurseProfileId, CancellationToken cancellationToken)
|
||||
=> TableNoTracking
|
||||
.Where(p => p.Id == nurseProfileId)
|
||||
.Select(p => new NurseBookingContext(p.UserId, p.User.Gender, p.IsVerified, p.IsAcceptingBookings))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
+5
@@ -11,6 +11,7 @@ using Baya.Infrastructure.Persistence.Interceptors;
|
||||
using Baya.Infrastructure.Persistence.Repositories.Common;
|
||||
using Baya.Infrastructure.Persistence.Services.Analytics;
|
||||
using Baya.Infrastructure.Persistence.Services.Audit;
|
||||
using Baya.Infrastructure.Persistence.Services.Booking;
|
||||
using Baya.Infrastructure.Persistence.Services.Configuration;
|
||||
using Baya.Infrastructure.Persistence.Services.Holidays;
|
||||
using Baya.Infrastructure.Persistence.Services.Notifications;
|
||||
@@ -53,6 +54,10 @@ public static class ServiceCollectionExtensions
|
||||
// Retention job seam (mock = in-process interval runner; real Hangfire/Quartz deferred).
|
||||
services.AddHostedService<NotificationRetentionHostedService>();
|
||||
|
||||
// Booking-request expiry sweep (same in-process interval-runner seam): auto-expires stale
|
||||
// pending/awaiting-payment requests. Also reachable via the admin manual-trigger endpoint.
|
||||
services.AddHostedService<BookingRequestExpiryHostedService>();
|
||||
|
||||
// Search (backend-phase-7). The index maintainer keeps nurse_search_index consistent inline inside
|
||||
// each source write's unit of work. The INurseSearch backend is config-selected — SQL is the real
|
||||
// MVP backend; a later ElasticNurseSearch drops in here with no caller change.
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Features.Booking.Commands.ExpireBookingRequests;
|
||||
using Mediator;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Services.Booking;
|
||||
|
||||
/// <summary>
|
||||
/// The recurring expiry sweep for <c>booking_requests</c> (reuses the b1 in-process interval-runner seam;
|
||||
/// real Hangfire/Quartz is deferred). Each tick sends <see cref="ExpireBookingRequestsCommand"/>, which
|
||||
/// transitions stale rows (<c>expired_no_response</c> / <c>payment_deadline_expired</c>) in bounded,
|
||||
/// idempotent batches. The interval is short because the payment window is only 30 minutes; there is no b1
|
||||
/// interval config key for booking expiry, so it is a documented constant.
|
||||
/// </summary>
|
||||
internal sealed class BookingRequestExpiryHostedService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<BookingRequestExpiryHostedService> logger) : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(1);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await SweepSafely(stoppingToken);
|
||||
|
||||
using var timer = new PeriodicTimer(Interval);
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
await SweepSafely(stoppingToken);
|
||||
}
|
||||
|
||||
private async Task SweepSafely(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var sender = scope.ServiceProvider.GetRequiredService<ISender>();
|
||||
var result = await sender.Send(new ExpireBookingRequestsCommand(), cancellationToken);
|
||||
if (result.IsSuccess && result.Result is { } counts && (counts.ExpiredNoResponse > 0 || counts.PaymentDeadlineExpired > 0))
|
||||
logger.LogInformation(
|
||||
"Booking-request expiry swept {NoResponse} expired-no-response and {PaymentExpired} payment-window-expired requests",
|
||||
counts.ExpiredNoResponse, counts.PaymentDeadlineExpired);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Host is shutting down — expected, don't log as an error.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Booking-request expiry sweep failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace Baya.Test.Api;
|
||||
|
||||
public class BookingRequestsApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
|
||||
{
|
||||
private static object ValidShapeButBadGender() => new
|
||||
{
|
||||
nurseId = 1,
|
||||
variantId = 1,
|
||||
patientId = 1,
|
||||
customerAddressId = 1,
|
||||
requestedDate = "2027-01-01",
|
||||
requestedTimeStart = "09:00:00",
|
||||
requestedTimeEnd = "13:00:00",
|
||||
requiredCaregiverGender = "other",
|
||||
customerNotes = "x"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Unauthenticated_Returns401()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/booking_requests/create", ValidShapeButBadGender());
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_InvalidGender_Returns400()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
await ProfileTestClient.AuthenticateAsync(factory, client, "09131000101", "customer");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/booking_requests/create", ValidShapeButBadGender());
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_Authenticated_ReturnsEmptyPagedEnvelope()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
await ProfileTestClient.AuthenticateAsync(factory, client, "09131000102", "customer");
|
||||
|
||||
var response = await client.GetAsync("/api/v1/booking_requests/list");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var data = await AuthTestClient.ReadDataAsync(response);
|
||||
Assert.Equal(0, data.GetProperty("total").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Expire_Unauthenticated_Returns401()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsync("/api/v1/admin_booking_requests/expire", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Expire_AsAdmin_ReturnsZeroCountsOnEmptySet()
|
||||
{
|
||||
var admin = factory.CreateClient();
|
||||
await AdminTestClient.AuthenticateAsync(factory, admin, "09131000103");
|
||||
|
||||
var response = await admin.PostAsync("/api/v1/admin_booking_requests/expire", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var data = await AuthTestClient.ReadDataAsync(response);
|
||||
Assert.Equal(0, data.GetProperty("expiredNoResponse").GetInt32());
|
||||
Assert.Equal(0, data.GetProperty("paymentDeadlineExpired").GetInt32());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Features.Booking.Commands.ExpireBookingRequests;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Baya.Test.Foundation.Booking;
|
||||
|
||||
public class BookingRequestExpiryTests : IDisposable
|
||||
{
|
||||
private readonly BookingTestHost _host = new();
|
||||
private readonly IDateTimeProvider _clock = Substitute.For<IDateTimeProvider>();
|
||||
private readonly INotificationDispatcher _notifications = Substitute.For<INotificationDispatcher>();
|
||||
|
||||
private static readonly DateTimeOffset Now = new(2026, 7, 6, 12, 0, 0, TimeSpan.Zero);
|
||||
private static readonly DateTime NowUtc = Now.UtcDateTime;
|
||||
|
||||
private ExpireBookingRequestsCommandHandler Handler()
|
||||
{
|
||||
_clock.UtcNow.Returns(Now);
|
||||
return new ExpireBookingRequestsCommandHandler(_host.UnitOfWork, _clock, _notifications);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sweep_TransitionsStalePendingAndAcceptedAndNotifies()
|
||||
{
|
||||
var stalePending = _host.AddPendingRequest(responseDeadline: NowUtc.AddHours(-1));
|
||||
var freshPending = _host.AddPendingRequest(responseDeadline: NowUtc.AddHours(5));
|
||||
var staleAccepted = _host.AddAcceptedRequest(paymentDeadline: NowUtc.AddMinutes(-1));
|
||||
var freshAccepted = _host.AddAcceptedRequest(paymentDeadline: NowUtc.AddMinutes(20));
|
||||
|
||||
var result = await Handler().Handle(new ExpireBookingRequestsCommand(), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(1, result.Result.ExpiredNoResponse);
|
||||
Assert.Equal(1, result.Result.PaymentDeadlineExpired);
|
||||
|
||||
Assert.Equal(BookingRequestStatus.ExpiredNoResponse, _host.StatusOf(stalePending.Id));
|
||||
Assert.Equal(BookingRequestStatus.PendingNurseResponse, _host.StatusOf(freshPending.Id));
|
||||
Assert.Equal(BookingRequestStatus.PaymentDeadlineExpired, _host.StatusOf(staleAccepted.Id));
|
||||
Assert.Equal(BookingRequestStatus.AcceptedAwaitingPayment, _host.StatusOf(freshAccepted.Id));
|
||||
|
||||
await _notifications.Received(2).DispatchAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sweep_IsIdempotent_SecondRunIsNoOp()
|
||||
{
|
||||
_host.AddPendingRequest(responseDeadline: NowUtc.AddHours(-1));
|
||||
|
||||
var first = await Handler().Handle(new ExpireBookingRequestsCommand(), CancellationToken.None);
|
||||
var second = await Handler().Handle(new ExpireBookingRequestsCommand(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, first.Result.ExpiredNoResponse);
|
||||
Assert.Equal(0, second.Result.ExpiredNoResponse);
|
||||
Assert.Equal(0, second.Result.PaymentDeadlineExpired);
|
||||
}
|
||||
|
||||
public void Dispose() => _host.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Features.Booking.Queries.GetBookingRequest;
|
||||
using Baya.Application.Features.Booking.Queries.ListBookingRequests;
|
||||
using Baya.Domain.Entities.User;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Baya.Test.Foundation.Booking;
|
||||
|
||||
public class BookingRequestQueryTests : IDisposable
|
||||
{
|
||||
private readonly BookingTestHost _host = new();
|
||||
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
|
||||
|
||||
private void SignInAs(int userId, params string[] roles)
|
||||
{
|
||||
_currentUser.UserId.Returns(userId);
|
||||
_currentUser.Roles.Returns(roles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_NurseInbox_ShowsPatientCounterpartyAndCustomerNotesOnly()
|
||||
{
|
||||
_host.AddPendingRequest();
|
||||
SignInAs(_host.NurseUserId, RoleNames.Nurse);
|
||||
|
||||
var handler = new ListBookingRequestsQueryHandler(_currentUser, _host.UnitOfWork);
|
||||
var result = await handler.Handle(new ListBookingRequestsQuery(), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(1, result.Result.Total);
|
||||
var item = result.Result.Items[0];
|
||||
Assert.Contains("پدر", item.CounterpartyName);
|
||||
Assert.Equal("Careful with the IV.", item.CustomerNotes);
|
||||
Assert.Null(item.NurseRating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_CustomerInbox_ShowsNurseCounterpartyWithoutCustomerNotes()
|
||||
{
|
||||
_host.AddPendingRequest();
|
||||
SignInAs(_host.CustomerUserId, RoleNames.Customer);
|
||||
|
||||
var handler = new ListBookingRequestsQueryHandler(_currentUser, _host.UnitOfWork);
|
||||
var result = await handler.Handle(new ListBookingRequestsQuery(), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(1, result.Result.Total);
|
||||
var item = result.Result.Items[0];
|
||||
Assert.Contains("زهرا", item.CounterpartyName);
|
||||
Assert.Null(item.CustomerNotes);
|
||||
Assert.NotNull(item.NurseRating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_AsNurse_MasksFullAddress()
|
||||
{
|
||||
var request = _host.AddPendingRequest();
|
||||
SignInAs(_host.NurseUserId, RoleNames.Nurse);
|
||||
|
||||
var handler = new GetBookingRequestQueryHandler(_currentUser, _host.UnitOfWork);
|
||||
var result = await handler.Handle(new GetBookingRequestQuery(request.Id), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Null(result.Result.AddressLine);
|
||||
Assert.Null(result.Result.PostalCode);
|
||||
// Coarse location + stage-1 notes are still available to the nurse.
|
||||
Assert.Equal("Careful with the IV.", result.Result.CustomerNotes);
|
||||
Assert.False(string.IsNullOrEmpty(result.Result.CityNameFa));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_AsOwningCustomer_ReturnsFullAddress()
|
||||
{
|
||||
var request = _host.AddPendingRequest();
|
||||
SignInAs(_host.CustomerUserId, RoleNames.Customer);
|
||||
|
||||
var handler = new GetBookingRequestQueryHandler(_currentUser, _host.UnitOfWork);
|
||||
var result = await handler.Handle(new GetBookingRequestQuery(request.Id), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(string.IsNullOrEmpty(result.Result.AddressLine));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_AsThirdParty_IsNotFound()
|
||||
{
|
||||
var request = _host.AddPendingRequest();
|
||||
SignInAs(999999, RoleNames.Customer);
|
||||
|
||||
var handler = new GetBookingRequestQueryHandler(_currentUser, _host.UnitOfWork);
|
||||
var result = await handler.Handle(new GetBookingRequestQuery(request.Id), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsNotFound);
|
||||
}
|
||||
|
||||
public void Dispose() => _host.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Baya.Domain.Entities.Booking;
|
||||
|
||||
namespace Baya.Test.Foundation.Booking;
|
||||
|
||||
public class BookingRequestTransitionsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(BookingRequestStatus.PendingNurseResponse, BookingRequestStatus.AcceptedAwaitingPayment, true)]
|
||||
[InlineData(BookingRequestStatus.PendingNurseResponse, BookingRequestStatus.RejectedByNurse, true)]
|
||||
[InlineData(BookingRequestStatus.PendingNurseResponse, BookingRequestStatus.ExpiredNoResponse, true)]
|
||||
[InlineData(BookingRequestStatus.PendingNurseResponse, BookingRequestStatus.CancelledByCustomer, true)]
|
||||
[InlineData(BookingRequestStatus.AcceptedAwaitingPayment, BookingRequestStatus.Converted, true)]
|
||||
[InlineData(BookingRequestStatus.AcceptedAwaitingPayment, BookingRequestStatus.PaymentDeadlineExpired, true)]
|
||||
[InlineData(BookingRequestStatus.AcceptedAwaitingPayment, BookingRequestStatus.CancelledByCustomer, true)]
|
||||
// Illegal edges.
|
||||
[InlineData(BookingRequestStatus.PendingNurseResponse, BookingRequestStatus.Converted, false)]
|
||||
[InlineData(BookingRequestStatus.PendingNurseResponse, BookingRequestStatus.PaymentDeadlineExpired, false)]
|
||||
[InlineData(BookingRequestStatus.AcceptedAwaitingPayment, BookingRequestStatus.RejectedByNurse, false)]
|
||||
[InlineData(BookingRequestStatus.AcceptedAwaitingPayment, BookingRequestStatus.ExpiredNoResponse, false)]
|
||||
// Terminals have no outgoing edges.
|
||||
[InlineData(BookingRequestStatus.Converted, BookingRequestStatus.CancelledByCustomer, false)]
|
||||
[InlineData(BookingRequestStatus.RejectedByNurse, BookingRequestStatus.AcceptedAwaitingPayment, false)]
|
||||
[InlineData(BookingRequestStatus.ExpiredNoResponse, BookingRequestStatus.AcceptedAwaitingPayment, false)]
|
||||
[InlineData(BookingRequestStatus.PaymentDeadlineExpired, BookingRequestStatus.CancelledByCustomer, false)]
|
||||
[InlineData(BookingRequestStatus.CancelledByCustomer, BookingRequestStatus.AcceptedAwaitingPayment, false)]
|
||||
public void CanTransition_MatchesForwardOnlyMachine(string from, string to, bool expected)
|
||||
=> Assert.Equal(expected, BookingRequestTransitions.CanTransition(from, to));
|
||||
|
||||
[Fact]
|
||||
public void Accept_FromPending_SetsPaymentDeadlineAndStatus()
|
||||
{
|
||||
var request = new BookingRequest { NurseResponseDeadlineAt = DateTime.UnixEpoch.AddHours(24) };
|
||||
var deadline = DateTime.UnixEpoch.AddMinutes(30);
|
||||
|
||||
request.Accept(deadline);
|
||||
|
||||
Assert.Equal(BookingRequestStatus.AcceptedAwaitingPayment, request.Status);
|
||||
Assert.Equal(deadline, request.PaymentDeadlineAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accept_FromTerminal_Throws()
|
||||
{
|
||||
var request = new BookingRequest { NurseResponseDeadlineAt = DateTime.UnixEpoch };
|
||||
request.Reject("no");
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => request.Accept(DateTime.UnixEpoch));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Baya.Domain.Entities.Geography;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Baya.Infrastructure.Persistence;
|
||||
using Baya.Infrastructure.Persistence.Repositories.Common;
|
||||
using Baya.Tests.Setup.Setups;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Baya.Test.Foundation.Booking;
|
||||
|
||||
/// <summary>
|
||||
/// A self-contained SQLite host exercising the real EF model (schema, filtered indexes, query filters) for
|
||||
/// the booking-request repository, expiry sweep, and queries end-to-end. Seeds one customer (user + profile
|
||||
/// + patient + address) and one bookable nurse (user + profile + active variant), and lets a test create
|
||||
/// requests in a chosen state and drive the real <see cref="UnitOfWork"/>.
|
||||
/// </summary>
|
||||
public sealed class BookingTestHost : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
public ApplicationDbContext Db { get; }
|
||||
public UnitOfWork UnitOfWork { get; }
|
||||
|
||||
public long CustomerId { get; }
|
||||
public int CustomerUserId { get; }
|
||||
public long NurseId { get; }
|
||||
public int NurseUserId { get; }
|
||||
public long PatientId { get; }
|
||||
public long AddressId { get; }
|
||||
public long VariantId { get; }
|
||||
|
||||
public BookingTestHost()
|
||||
{
|
||||
_connection = new SqliteConnection("DataSource=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
Db = new ApplicationDbContext(options, TestFieldEncryptor.Instance);
|
||||
Db.Database.EnsureCreated();
|
||||
UnitOfWork = new UnitOfWork(Db);
|
||||
|
||||
var province = new Province { NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
|
||||
Db.Set<Province>().Add(province);
|
||||
Db.SaveChanges();
|
||||
|
||||
var city = new City { ProvinceId = province.Id, NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
|
||||
Db.Set<City>().Add(city);
|
||||
Db.SaveChanges();
|
||||
|
||||
var category = new ServiceCategory { NameFa = "سالمند", NameEn = "Elderly", SortOrder = 1, IsActive = true };
|
||||
Db.Set<ServiceCategory>().Add(category);
|
||||
Db.SaveChanges();
|
||||
|
||||
var customerUser = new User { UserName = "cust1", PhoneNumber = "09120000001", Gender = "male", Name = "علی", FamilyName = "رضایی", IsActive = true };
|
||||
Db.Users.Add(customerUser);
|
||||
Db.SaveChanges();
|
||||
CustomerUserId = customerUser.Id;
|
||||
|
||||
var customer = new CustomerProfile { UserId = customerUser.Id };
|
||||
Db.Set<CustomerProfile>().Add(customer);
|
||||
Db.SaveChanges();
|
||||
CustomerId = customer.Id;
|
||||
|
||||
var patient = new Patient { CustomerId = customer.Id, DisplayName = "پدر", FirstName = "حسن", LastName = "رضایی", Gender = "male", IsActive = true };
|
||||
Db.Set<Patient>().Add(patient);
|
||||
Db.SaveChanges();
|
||||
PatientId = patient.Id;
|
||||
|
||||
var address = new CustomerAddress
|
||||
{
|
||||
CustomerId = customer.Id, CityId = city.Id, Title = "خانه",
|
||||
AddressLine = "خیابان اول", PostalCode = "1111111111", RecipientName = "علی", RecipientPhone = "09120000001", IsPrimary = true
|
||||
};
|
||||
Db.Set<CustomerAddress>().Add(address);
|
||||
Db.SaveChanges();
|
||||
AddressId = address.Id;
|
||||
|
||||
var nurseUser = new User { UserName = "nurse1", PhoneNumber = "09120000002", Gender = "female", Name = "زهرا", FamilyName = "احمدی", IsActive = true };
|
||||
Db.Users.Add(nurseUser);
|
||||
Db.SaveChanges();
|
||||
NurseUserId = nurseUser.Id;
|
||||
|
||||
var nurse = new NurseProfile { UserId = nurseUser.Id };
|
||||
nurse.MarkVerified();
|
||||
nurse.SetAcceptingBookings(true);
|
||||
Db.Set<NurseProfile>().Add(nurse);
|
||||
Db.SaveChanges();
|
||||
NurseId = nurse.Id;
|
||||
|
||||
var variant = new NurseServiceVariant
|
||||
{
|
||||
NurseId = nurse.Id, ServiceCategoryId = category.Id, Price = 5_000_000, PriceUnit = "per_day",
|
||||
DisplayName = "مراقبت روزانه", OptionSetHash = "hash", IsActive = true
|
||||
};
|
||||
Db.Set<NurseServiceVariant>().Add(variant);
|
||||
Db.SaveChanges();
|
||||
VariantId = variant.Id;
|
||||
}
|
||||
|
||||
/// <summary>Inserts a pending request with the given response deadline (defaults far in the future).</summary>
|
||||
public BookingRequest AddPendingRequest(DateTime? responseDeadline = null)
|
||||
{
|
||||
var request = new BookingRequest
|
||||
{
|
||||
CustomerId = CustomerId,
|
||||
NurseId = NurseId,
|
||||
PatientId = PatientId,
|
||||
VariantId = VariantId,
|
||||
CustomerAddressId = AddressId,
|
||||
RequiredCaregiverGender = CaregiverGender.Any,
|
||||
RequestedDate = new DateOnly(2026, 8, 1),
|
||||
RequestedTimeStart = new TimeOnly(9, 0),
|
||||
RequestedTimeEnd = new TimeOnly(13, 0),
|
||||
CustomerNotes = "Careful with the IV.",
|
||||
NurseResponseDeadlineAt = responseDeadline ?? new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||
};
|
||||
Db.Set<BookingRequest>().Add(request);
|
||||
Db.SaveChanges();
|
||||
return request;
|
||||
}
|
||||
|
||||
/// <summary>Inserts an accepted request whose payment window closes at <paramref name="paymentDeadline"/>.</summary>
|
||||
public BookingRequest AddAcceptedRequest(DateTime paymentDeadline)
|
||||
{
|
||||
var request = AddPendingRequest();
|
||||
request.Accept(paymentDeadline);
|
||||
Db.SaveChanges();
|
||||
return request;
|
||||
}
|
||||
|
||||
public string StatusOf(long id)
|
||||
=> Db.Set<BookingRequest>().AsNoTracking().Where(r => r.Id == id).Select(r => r.Status).Single();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Db.Dispose();
|
||||
_connection.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Features.Booking.Commands.CreateBookingRequest;
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Application.Models.Identity;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Baya.Test.Foundation.Booking;
|
||||
|
||||
public class CreateBookingRequestHandlerTests
|
||||
{
|
||||
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
|
||||
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
|
||||
private readonly IPlatformConfig _config = Substitute.For<IPlatformConfig>();
|
||||
private readonly IDateTimeProvider _clock = Substitute.For<IDateTimeProvider>();
|
||||
private readonly INotificationDispatcher _notifications = Substitute.For<INotificationDispatcher>();
|
||||
|
||||
private readonly ICustomerProfileRepository _customers = Substitute.For<ICustomerProfileRepository>();
|
||||
private readonly IPatientRepository _patients = Substitute.For<IPatientRepository>();
|
||||
private readonly ICustomerAddressRepository _addresses = Substitute.For<ICustomerAddressRepository>();
|
||||
private readonly INurseServiceVariantRepository _variants = Substitute.For<INurseServiceVariantRepository>();
|
||||
private readonly INurseProfileRepository _nurses = Substitute.For<INurseProfileRepository>();
|
||||
private readonly IBookingRequestRepository _requests = Substitute.For<IBookingRequestRepository>();
|
||||
|
||||
private const int CustomerUserId = 7;
|
||||
private const long CustomerId = 100;
|
||||
private const long NurseId = 42;
|
||||
private const int NurseUserId = 9;
|
||||
private const long PatientId = 5;
|
||||
private const long AddressId = 6;
|
||||
private const long VariantId = 8;
|
||||
private static readonly DateTimeOffset Now = new(2026, 7, 6, 10, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public CreateBookingRequestHandlerTests()
|
||||
{
|
||||
_currentUser.UserId.Returns(CustomerUserId);
|
||||
_currentUser.Roles.Returns([RoleNames.Customer]);
|
||||
_clock.UtcNow.Returns(Now);
|
||||
|
||||
_unitOfWork.CustomerProfileRepository.Returns(_customers);
|
||||
_unitOfWork.PatientRepository.Returns(_patients);
|
||||
_unitOfWork.CustomerAddressRepository.Returns(_addresses);
|
||||
_unitOfWork.NurseServiceVariantRepository.Returns(_variants);
|
||||
_unitOfWork.NurseProfileRepository.Returns(_nurses);
|
||||
_unitOfWork.BookingRequestRepository.Returns(_requests);
|
||||
|
||||
_customers.GetProfileIdByUserIdAsync(CustomerUserId, Arg.Any<CancellationToken>()).Returns(CustomerId);
|
||||
_patients.GetOwnedAsync(PatientId, CustomerId, Arg.Any<CancellationToken>())
|
||||
.Returns(new Patient { CustomerId = CustomerId, DisplayName = "پدر", Gender = "male" });
|
||||
_addresses.GetOwnedAsync(AddressId, CustomerId, Arg.Any<CancellationToken>())
|
||||
.Returns(new CustomerAddress { CustomerId = CustomerId });
|
||||
_variants.GetOwnedAsync(VariantId, NurseId, Arg.Any<CancellationToken>())
|
||||
.Returns(new NurseServiceVariant { NurseId = NurseId, IsActive = true });
|
||||
_nurses.GetBookingContextByIdAsync(NurseId, Arg.Any<CancellationToken>())
|
||||
.Returns(new NurseBookingContext(NurseUserId, "female", IsVerified: true, IsAcceptingBookings: true));
|
||||
_config.GetConfig<int>("nurse_response_deadline_hours", Arg.Any<CancellationToken>()).Returns(24);
|
||||
_requests.GetDetailAsync(Arg.Any<long>(), Arg.Any<CancellationToken>()).Returns(DetailStub());
|
||||
}
|
||||
|
||||
private CreateBookingRequestCommandHandler Handler()
|
||||
=> new(_currentUser, _unitOfWork, _config, _clock, _notifications);
|
||||
|
||||
private static CreateBookingRequestCommand Command(string gender = "female")
|
||||
=> new(NurseId, VariantId, PatientId, AddressId,
|
||||
new DateOnly(2026, 8, 1), new TimeOnly(9, 0), new TimeOnly(13, 0), gender, "Careful with the IV.");
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Valid_FreezesResponseDeadlineFromConfigAndNotifiesNurse()
|
||||
{
|
||||
var result = await Handler().Handle(Command(), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
await _requests.Received(1).AddAsync(
|
||||
Arg.Is<BookingRequest>(r =>
|
||||
r.CustomerId == CustomerId && r.NurseId == NurseId && r.PatientId == PatientId
|
||||
&& r.Status == BookingRequestStatus.PendingNurseResponse
|
||||
&& r.PaymentDeadlineAt == null
|
||||
&& r.NurseResponseDeadlineAt == Now.AddHours(24).UtcDateTime),
|
||||
Arg.Any<CancellationToken>());
|
||||
await _unitOfWork.Received(1).CommitAsync();
|
||||
await _notifications.Received(1).DispatchAsync(
|
||||
Arg.Is<Notification>(n => n.RecipientUserId == NurseUserId && n.Type == "booking_request_received"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_AnyGender_MatchesRegardlessOfNurseGender()
|
||||
{
|
||||
var result = await Handler().Handle(Command("any"), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_SameGenderMismatch_FailsNamingTheConflict()
|
||||
{
|
||||
// Nurse is female; a male requirement must not match.
|
||||
var result = await Handler().Handle(Command("male"), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.False(result.IsNotFound);
|
||||
await _requests.DidNotReceive().AddAsync(Arg.Any<BookingRequest>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_CrossCustomerPatient_IsNotFoundAndCreatesNothing()
|
||||
{
|
||||
_patients.GetOwnedAsync(PatientId, CustomerId, Arg.Any<CancellationToken>()).Returns((Patient)null);
|
||||
|
||||
var result = await Handler().Handle(Command(), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsNotFound);
|
||||
await _requests.DidNotReceive().AddAsync(Arg.Any<BookingRequest>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_AddressNotOwned_IsNotFound()
|
||||
{
|
||||
_addresses.GetOwnedAsync(AddressId, CustomerId, Arg.Any<CancellationToken>()).Returns((CustomerAddress)null);
|
||||
|
||||
var result = await Handler().Handle(Command(), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsNotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_VariantNotThisNurses_IsNotFound()
|
||||
{
|
||||
_variants.GetOwnedAsync(VariantId, NurseId, Arg.Any<CancellationToken>()).Returns((NurseServiceVariant)null);
|
||||
|
||||
var result = await Handler().Handle(Command(), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsNotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_InactiveVariant_Fails()
|
||||
{
|
||||
_variants.GetOwnedAsync(VariantId, NurseId, Arg.Any<CancellationToken>())
|
||||
.Returns(new NurseServiceVariant { NurseId = NurseId, IsActive = false });
|
||||
|
||||
var result = await Handler().Handle(Command(), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
await _requests.DidNotReceive().AddAsync(Arg.Any<BookingRequest>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_NurseNotAcceptingOrUnverified_Fails()
|
||||
{
|
||||
_nurses.GetBookingContextByIdAsync(NurseId, Arg.Any<CancellationToken>())
|
||||
.Returns(new NurseBookingContext(NurseUserId, "female", IsVerified: true, IsAcceptingBookings: false));
|
||||
|
||||
var result = await Handler().Handle(Command(), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_PastDate_Fails()
|
||||
{
|
||||
var command = Command() with { RequestedDate = new DateOnly(2026, 7, 5) };
|
||||
|
||||
var result = await Handler().Handle(command, CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_NonCustomer_IsForbidden()
|
||||
{
|
||||
_currentUser.Roles.Returns([RoleNames.Nurse]);
|
||||
|
||||
var result = await Handler().Handle(Command(), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsForbidden);
|
||||
}
|
||||
|
||||
private static BookingRequestDetailProjection DetailStub()
|
||||
=> new(
|
||||
1, BookingRequestStatus.PendingNurseResponse, CustomerId, NurseId,
|
||||
"پرستار", 0m, 0, PatientId, "پدر", VariantId, "خدمت", "per_day",
|
||||
AddressId, "خانه", 1, "تهران", "Tehran", null, null, null,
|
||||
"line", "postal", "recipient", "phone",
|
||||
"female", new DateOnly(2026, 8, 1), new TimeOnly(9, 0), new TimeOnly(13, 0),
|
||||
"notes", Now.AddHours(24).UtcDateTime, null, null, Now);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Features.Booking.Commands.AcceptBookingRequest;
|
||||
using Baya.Application.Features.Booking.Commands.CancelBookingRequest;
|
||||
using Baya.Application.Features.Booking.Commands.RejectBookingRequest;
|
||||
using Baya.Application.Models.Booking;
|
||||
using Baya.Domain.Entities.Booking;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Baya.Test.Foundation.Booking;
|
||||
|
||||
public class RespondBookingRequestHandlerTests
|
||||
{
|
||||
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
|
||||
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
|
||||
private readonly IPlatformConfig _config = Substitute.For<IPlatformConfig>();
|
||||
private readonly IDateTimeProvider _clock = Substitute.For<IDateTimeProvider>();
|
||||
private readonly INotificationDispatcher _notifications = Substitute.For<INotificationDispatcher>();
|
||||
private readonly INurseProfileRepository _nurses = Substitute.For<INurseProfileRepository>();
|
||||
private readonly ICustomerProfileRepository _customers = Substitute.For<ICustomerProfileRepository>();
|
||||
private readonly IBookingRequestRepository _requests = Substitute.For<IBookingRequestRepository>();
|
||||
|
||||
private const int NurseUserId = 9;
|
||||
private const long NurseId = 42;
|
||||
private const int CustomerUserId = 7;
|
||||
private const long CustomerId = 100;
|
||||
private const int CustomerAccountUserId = 5;
|
||||
private const long RequestId = 1;
|
||||
private static readonly DateTimeOffset Now = new(2026, 7, 6, 10, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public RespondBookingRequestHandlerTests()
|
||||
{
|
||||
_clock.UtcNow.Returns(Now);
|
||||
_unitOfWork.NurseProfileRepository.Returns(_nurses);
|
||||
_unitOfWork.CustomerProfileRepository.Returns(_customers);
|
||||
_unitOfWork.BookingRequestRepository.Returns(_requests);
|
||||
_nurses.GetProfileIdByUserIdAsync(NurseUserId, Arg.Any<CancellationToken>()).Returns(NurseId);
|
||||
_customers.GetProfileIdByUserIdAsync(CustomerUserId, Arg.Any<CancellationToken>()).Returns(CustomerId);
|
||||
_config.GetConfig<int>("booking_payment_deadline_minutes", Arg.Any<CancellationToken>()).Returns(30);
|
||||
_requests.GetDetailAsync(Arg.Any<long>(), Arg.Any<CancellationToken>()).Returns(DetailStub(BookingRequestStatus.PendingNurseResponse));
|
||||
}
|
||||
|
||||
private static BookingRequest PendingRequest(DateTimeOffset responseDeadline)
|
||||
=> new()
|
||||
{
|
||||
CustomerId = CustomerId,
|
||||
NurseId = NurseId,
|
||||
NurseResponseDeadlineAt = responseDeadline.UtcDateTime,
|
||||
Customer = new CustomerProfile { UserId = CustomerAccountUserId }
|
||||
};
|
||||
|
||||
// ---- Accept ----
|
||||
|
||||
[Fact]
|
||||
public async Task Accept_Pending_SetsThirtyMinuteWindowAndNotifiesCustomer()
|
||||
{
|
||||
_currentUser.UserId.Returns(NurseUserId);
|
||||
_currentUser.Roles.Returns([RoleNames.Nurse]);
|
||||
var request = PendingRequest(Now.AddHours(2));
|
||||
_requests.GetTrackedForNurseAsync(RequestId, NurseId, Arg.Any<CancellationToken>()).Returns(request);
|
||||
|
||||
var handler = new AcceptBookingRequestCommandHandler(_currentUser, _unitOfWork, _config, _clock, _notifications);
|
||||
var result = await handler.Handle(new AcceptBookingRequestCommand(RequestId), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(BookingRequestStatus.AcceptedAwaitingPayment, request.Status);
|
||||
Assert.Equal(Now.AddMinutes(30).UtcDateTime, request.PaymentDeadlineAt);
|
||||
await _unitOfWork.Received(1).CommitAsync();
|
||||
await _notifications.Received(1).DispatchAsync(
|
||||
Arg.Is<Notification>(n => n.RecipientUserId == CustomerAccountUserId && n.Type == "booking_request_accepted"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Accept_AfterResponseDeadline_IsConflict()
|
||||
{
|
||||
_currentUser.UserId.Returns(NurseUserId);
|
||||
_currentUser.Roles.Returns([RoleNames.Nurse]);
|
||||
var request = PendingRequest(Now.AddHours(-1));
|
||||
_requests.GetTrackedForNurseAsync(RequestId, NurseId, Arg.Any<CancellationToken>()).Returns(request);
|
||||
|
||||
var handler = new AcceptBookingRequestCommandHandler(_currentUser, _unitOfWork, _config, _clock, _notifications);
|
||||
var result = await handler.Handle(new AcceptBookingRequestCommand(RequestId), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsConflict);
|
||||
await _unitOfWork.DidNotReceive().CommitAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Accept_NonPending_IsConflict()
|
||||
{
|
||||
_currentUser.UserId.Returns(NurseUserId);
|
||||
_currentUser.Roles.Returns([RoleNames.Nurse]);
|
||||
var request = PendingRequest(Now.AddHours(2));
|
||||
request.Reject("already declined");
|
||||
_requests.GetTrackedForNurseAsync(RequestId, NurseId, Arg.Any<CancellationToken>()).Returns(request);
|
||||
|
||||
var handler = new AcceptBookingRequestCommandHandler(_currentUser, _unitOfWork, _config, _clock, _notifications);
|
||||
var result = await handler.Handle(new AcceptBookingRequestCommand(RequestId), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsConflict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Accept_NotAssignedNurse_IsNotFound()
|
||||
{
|
||||
_currentUser.UserId.Returns(NurseUserId);
|
||||
_currentUser.Roles.Returns([RoleNames.Nurse]);
|
||||
_requests.GetTrackedForNurseAsync(RequestId, NurseId, Arg.Any<CancellationToken>()).Returns((BookingRequest)null);
|
||||
|
||||
var handler = new AcceptBookingRequestCommandHandler(_currentUser, _unitOfWork, _config, _clock, _notifications);
|
||||
var result = await handler.Handle(new AcceptBookingRequestCommand(RequestId), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsNotFound);
|
||||
}
|
||||
|
||||
// ---- Reject ----
|
||||
|
||||
[Fact]
|
||||
public async Task Reject_Pending_StoresReasonAndNotifies()
|
||||
{
|
||||
_currentUser.UserId.Returns(NurseUserId);
|
||||
_currentUser.Roles.Returns([RoleNames.Nurse]);
|
||||
var request = PendingRequest(Now.AddHours(2));
|
||||
_requests.GetTrackedForNurseAsync(RequestId, NurseId, Arg.Any<CancellationToken>()).Returns(request);
|
||||
|
||||
var handler = new RejectBookingRequestCommandHandler(_currentUser, _unitOfWork, _notifications);
|
||||
var result = await handler.Handle(new RejectBookingRequestCommand("Fully booked", RequestId), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(BookingRequestStatus.RejectedByNurse, request.Status);
|
||||
Assert.Equal("Fully booked", request.NurseRejectionReason);
|
||||
await _notifications.Received(1).DispatchAsync(
|
||||
Arg.Is<Notification>(n => n.Type == "booking_request_rejected"), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reject_NonPending_IsConflict()
|
||||
{
|
||||
_currentUser.UserId.Returns(NurseUserId);
|
||||
_currentUser.Roles.Returns([RoleNames.Nurse]);
|
||||
var request = PendingRequest(Now.AddHours(2));
|
||||
request.Accept(Now.AddMinutes(30).UtcDateTime);
|
||||
_requests.GetTrackedForNurseAsync(RequestId, NurseId, Arg.Any<CancellationToken>()).Returns(request);
|
||||
|
||||
var handler = new RejectBookingRequestCommandHandler(_currentUser, _unitOfWork, _notifications);
|
||||
var result = await handler.Handle(new RejectBookingRequestCommand("late", RequestId), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsConflict);
|
||||
}
|
||||
|
||||
// ---- Cancel ----
|
||||
|
||||
[Fact]
|
||||
public async Task Cancel_FromAcceptedAwaitingPayment_Succeeds()
|
||||
{
|
||||
_currentUser.UserId.Returns(CustomerUserId);
|
||||
_currentUser.Roles.Returns([RoleNames.Customer]);
|
||||
var request = PendingRequest(Now.AddHours(2));
|
||||
request.Accept(Now.AddMinutes(30).UtcDateTime);
|
||||
_requests.GetTrackedForCustomerAsync(RequestId, CustomerId, Arg.Any<CancellationToken>()).Returns(request);
|
||||
_requests.GetDetailAsync(Arg.Any<long>(), Arg.Any<CancellationToken>())
|
||||
.Returns(DetailStub(BookingRequestStatus.CancelledByCustomer));
|
||||
|
||||
var handler = new CancelBookingRequestCommandHandler(_currentUser, _unitOfWork);
|
||||
var result = await handler.Handle(new CancelBookingRequestCommand(RequestId), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(BookingRequestStatus.CancelledByCustomer, request.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancel_FromTerminal_IsConflict()
|
||||
{
|
||||
_currentUser.UserId.Returns(CustomerUserId);
|
||||
_currentUser.Roles.Returns([RoleNames.Customer]);
|
||||
var request = PendingRequest(Now.AddHours(2));
|
||||
request.Reject("declined");
|
||||
_requests.GetTrackedForCustomerAsync(RequestId, CustomerId, Arg.Any<CancellationToken>()).Returns(request);
|
||||
|
||||
var handler = new CancelBookingRequestCommandHandler(_currentUser, _unitOfWork);
|
||||
var result = await handler.Handle(new CancelBookingRequestCommand(RequestId), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsConflict);
|
||||
await _unitOfWork.DidNotReceive().CommitAsync();
|
||||
}
|
||||
|
||||
private static BookingRequestDetailProjection DetailStub(string status)
|
||||
=> new(
|
||||
RequestId, status, CustomerId, NurseId,
|
||||
"پرستار", 0m, 0, 5, "پدر", 8, "خدمت", "per_day",
|
||||
6, "خانه", 1, "تهران", "Tehran", null, null, null,
|
||||
"line", "postal", "recipient", "phone",
|
||||
"any", new DateOnly(2026, 8, 1), new TimeOnly(9, 0), new TimeOnly(13, 0),
|
||||
null, Now.AddHours(24).UtcDateTime, null, null, Now);
|
||||
}
|
||||
Reference in New Issue
Block a user