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": [
|
"servers": [
|
||||||
{
|
{
|
||||||
"url": "http://localhost"
|
"url": "https://localhost:5002"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"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": {
|
"/api/v1/admin_catalog/create_category": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"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": {
|
"/api/v1/catalog/categories": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -3786,7 +4355,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Me"
|
"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",
|
"operationId": "Me_SelectRole",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"x-name": "command",
|
"x-name": "command",
|
||||||
@@ -7200,6 +7769,41 @@
|
|||||||
500
|
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": {
|
"ApiResultOfServiceCategoryDto": {
|
||||||
"allOf": [
|
"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": {
|
"ApiResultOfPagedResultOfServiceCategoryDto": {
|
||||||
"allOf": [
|
"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>
|
- **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
|
## backend-phase-7 — Search & matching (nurse search index) — 2026-07-05
|
||||||
- **Shipped:** the discovery layer via one additive migration — new **`search`** schema, **1 table**
|
- **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 ×
|
`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 | 🔴 |
|
| `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 | 🟡 |
|
| `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 | 🟡 |
|
| `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) | 🟡 |
|
| `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 | 🟡 |
|
| `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** | 🟡 |
|
| `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/
|
src/
|
||||||
├── Core/
|
├── 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.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; + 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.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/
|
├── 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.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.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
|
│ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net
|
||||||
├── API/
|
├── 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)
|
│ ├── 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)
|
│ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models (User only)
|
||||||
├── Shared/Baya.SharedKernel Extensions + validation base
|
├── 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.
|
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.
|
- **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
|
**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
|
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
|
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
|
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.
|
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
|
## 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
|
/// <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>
|
/// ownership inquiry needs. NULL when the user has no nurse profile yet.</summary>
|
||||||
Task<NurseIdentityContext?> GetIdentityContextByUserIdAsync(int userId, CancellationToken cancellationToken);
|
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 ICatalogRepository CatalogRepository { get; }
|
||||||
public INurseServiceVariantRepository NurseServiceVariantRepository { get; }
|
public INurseServiceVariantRepository NurseServiceVariantRepository { get; }
|
||||||
public IVerificationRepository VerificationRepository { get; }
|
public IVerificationRepository VerificationRepository { get; }
|
||||||
|
public IBookingRequestRepository BookingRequestRepository { get; }
|
||||||
Task CommitAsync();
|
Task CommitAsync();
|
||||||
ValueTask RollBackAsync();
|
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");
|
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 =>
|
modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
@@ -3076,6 +3165,49 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
|||||||
.HasForeignKey("ActorUserId");
|
.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 =>
|
modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse")
|
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 ICatalogRepository CatalogRepository { get; }
|
||||||
public INurseServiceVariantRepository NurseServiceVariantRepository { get; }
|
public INurseServiceVariantRepository NurseServiceVariantRepository { get; }
|
||||||
public IVerificationRepository VerificationRepository { get; }
|
public IVerificationRepository VerificationRepository { get; }
|
||||||
|
public IBookingRequestRepository BookingRequestRepository { get; }
|
||||||
|
|
||||||
public UnitOfWork(ApplicationDbContext db)
|
public UnitOfWork(ApplicationDbContext db)
|
||||||
{
|
{
|
||||||
@@ -36,6 +37,7 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
CatalogRepository = new CatalogRepository(_db);
|
CatalogRepository = new CatalogRepository(_db);
|
||||||
NurseServiceVariantRepository = new NurseServiceVariantRepository(_db);
|
NurseServiceVariantRepository = new NurseServiceVariantRepository(_db);
|
||||||
VerificationRepository = new VerificationRepository(_db);
|
VerificationRepository = new VerificationRepository(_db);
|
||||||
|
BookingRequestRepository = new BookingRequestRepository(_db);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task CommitAsync()
|
public Task CommitAsync()
|
||||||
|
|||||||
+6
@@ -49,4 +49,10 @@ internal sealed class NurseProfileRepository : BaseAsyncRepository<NurseProfile>
|
|||||||
.Where(p => p.UserId == userId)
|
.Where(p => p.UserId == userId)
|
||||||
.Select(p => new NurseIdentityContext(p.Id, p.User.NationalId))
|
.Select(p => new NurseIdentityContext(p.Id, p.User.NationalId))
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.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.Repositories.Common;
|
||||||
using Baya.Infrastructure.Persistence.Services.Analytics;
|
using Baya.Infrastructure.Persistence.Services.Analytics;
|
||||||
using Baya.Infrastructure.Persistence.Services.Audit;
|
using Baya.Infrastructure.Persistence.Services.Audit;
|
||||||
|
using Baya.Infrastructure.Persistence.Services.Booking;
|
||||||
using Baya.Infrastructure.Persistence.Services.Configuration;
|
using Baya.Infrastructure.Persistence.Services.Configuration;
|
||||||
using Baya.Infrastructure.Persistence.Services.Holidays;
|
using Baya.Infrastructure.Persistence.Services.Holidays;
|
||||||
using Baya.Infrastructure.Persistence.Services.Notifications;
|
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).
|
// Retention job seam (mock = in-process interval runner; real Hangfire/Quartz deferred).
|
||||||
services.AddHostedService<NotificationRetentionHostedService>();
|
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
|
// 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
|
// 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.
|
// 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