diff --git a/dev/contracts/domains/booking-requests.md b/dev/contracts/domains/booking-requests.md new file mode 100644 index 0000000..c63f0ce --- /dev/null +++ b/dev/contracts/domains/booking-requests.md @@ -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` (`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). diff --git a/dev/contracts/openapi/swagger.v1.json b/dev/contracts/openapi/swagger.v1.json index 3816927..5ffee71 100644 --- a/dev/contracts/openapi/swagger.v1.json +++ b/dev/contracts/openapi/swagger.v1.json @@ -7,10 +7,75 @@ }, "servers": [ { - "url": "http://localhost" + "url": "https://localhost:5002" } ], "paths": { + "/api/v1/admin_booking_requests/expire": { + "post": { + "tags": [ + "AdminBookingRequests" + ], + "operationId": "AdminBookingRequests_Expire", + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfExpireBookingRequestsResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/admin_catalog/create_category": { "post": { "tags": [ @@ -2484,6 +2549,510 @@ ] } }, + "/api/v1/booking_requests/create": { + "post": { + "tags": [ + "BookingRequests" + ], + "summary": "Creates a BookingRequest", + "operationId": "BookingRequests_Create", + "requestBody": { + "x-name": "command", + "description": "A BookingRequest representation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateBookingRequestCommand" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfBookingRequestDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/booking_requests/cancel/{id}": { + "post": { + "tags": [ + "BookingRequests" + ], + "operationId": "BookingRequests_Cancel", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfBookingRequestDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/booking_requests/accept/{id}": { + "post": { + "tags": [ + "BookingRequests" + ], + "operationId": "BookingRequests_Accept", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfBookingRequestDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/booking_requests/reject/{id}": { + "post": { + "tags": [ + "BookingRequests" + ], + "operationId": "BookingRequests_Reject", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RejectBookingRequestCommand" + } + } + }, + "required": true, + "x-position": 2 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfBookingRequestDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/booking_requests/list": { + "get": { + "tags": [ + "BookingRequests" + ], + "operationId": "BookingRequests_List", + "parameters": [ + { + "name": "Status", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 1 + }, + { + "name": "Role", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 2 + }, + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 3 + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 4 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfPagedResultOfBookingRequestListItemDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/booking_requests/get/{id}": { + "get": { + "tags": [ + "BookingRequests" + ], + "summary": "Retrieves a BookingRequest by unique id", + "operationId": "BookingRequests_Get", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "A unique id for the BookingRequest", + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfBookingRequestDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/catalog/categories": { "get": { "tags": [ @@ -3786,7 +4355,7 @@ "tags": [ "Me" ], - "description": "Role claims live inside the access token \u2014 after selecting a role the client should\n refresh its tokens to pick the new role up.", + "description": "Role claims live inside the access token — after selecting a role the client should\n refresh its tokens to pick the new role up.", "operationId": "Me_SelectRole", "requestBody": { "x-name": "command", @@ -7200,6 +7769,41 @@ 500 ] }, + "ApiResultOfExpireBookingRequestsResult": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/ExpireBookingRequestsResult" + } + ] + } + } + } + ] + }, + "ExpireBookingRequestsResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "expiredNoResponse": { + "type": "integer", + "format": "int32" + }, + "paymentDeadlineExpired": { + "type": "integer", + "format": "int32" + } + } + }, "ApiResultOfServiceCategoryDto": { "allOf": [ { @@ -8578,6 +9182,306 @@ } } }, + "ApiResultOfBookingRequestDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/BookingRequestDto" + } + ] + } + } + } + ] + }, + "BookingRequestDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "string" + }, + "nurseId": { + "type": "integer", + "format": "int64" + }, + "nurseName": { + "type": "string" + }, + "nurseRating": { + "type": "number", + "format": "decimal" + }, + "nurseTotalReviews": { + "type": "integer", + "format": "int32" + }, + "patientId": { + "type": "integer", + "format": "int64" + }, + "patientName": { + "type": "string" + }, + "variantId": { + "type": "integer", + "format": "int64" + }, + "variantLabel": { + "type": "string" + }, + "variantPriceUnit": { + "type": "string" + }, + "customerAddressId": { + "type": "integer", + "format": "int64" + }, + "addressTitle": { + "type": "string" + }, + "cityId": { + "type": "integer", + "format": "int64" + }, + "cityNameFa": { + "type": "string" + }, + "cityNameEn": { + "type": "string" + }, + "districtId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "districtNameFa": { + "type": "string", + "nullable": true + }, + "districtNameEn": { + "type": "string", + "nullable": true + }, + "addressLine": { + "type": "string", + "nullable": true + }, + "postalCode": { + "type": "string", + "nullable": true + }, + "recipientName": { + "type": "string", + "nullable": true + }, + "recipientPhone": { + "type": "string", + "nullable": true + }, + "requiredCaregiverGender": { + "type": "string", + "nullable": true + }, + "requestedDate": { + "type": "string", + "format": "date" + }, + "requestedTimeStart": { + "type": "string", + "format": "time" + }, + "requestedTimeEnd": { + "type": "string", + "format": "time" + }, + "customerNotes": { + "type": "string", + "nullable": true + }, + "nurseResponseDeadlineAt": { + "type": "string", + "format": "date-time" + }, + "paymentDeadlineAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "nurseRejectionReason": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "CreateBookingRequestCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "nurseId": { + "type": "integer", + "format": "int64" + }, + "variantId": { + "type": "integer", + "format": "int64" + }, + "patientId": { + "type": "integer", + "format": "int64" + }, + "customerAddressId": { + "type": "integer", + "format": "int64" + }, + "requestedDate": { + "type": "string", + "format": "date" + }, + "requestedTimeStart": { + "type": "string", + "format": "time" + }, + "requestedTimeEnd": { + "type": "string", + "format": "time" + }, + "requiredCaregiverGender": { + "type": "string" + }, + "customerNotes": { + "type": "string", + "nullable": true + } + } + }, + "RejectBookingRequestCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "reason": { + "type": "string", + "nullable": true + }, + "id": { + "type": "integer", + "format": "int64" + } + } + }, + "ApiResultOfPagedResultOfBookingRequestListItemDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/PagedResultOfBookingRequestListItemDto" + } + ] + } + } + } + ] + }, + "PagedResultOfBookingRequestListItemDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/BookingRequestListItemDto" + } + }, + "total": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + } + } + }, + "BookingRequestListItemDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "string" + }, + "counterpartyName": { + "type": "string" + }, + "nurseRating": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "requiredCaregiverGender": { + "type": "string", + "nullable": true + }, + "requestedDate": { + "type": "string", + "format": "date" + }, + "requestedTimeStart": { + "type": "string", + "format": "time" + }, + "requestedTimeEnd": { + "type": "string", + "format": "time" + }, + "nurseResponseDeadlineAt": { + "type": "string", + "format": "date-time" + }, + "paymentDeadlineAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "customerNotes": { + "type": "string", + "nullable": true + } + } + }, "ApiResultOfPagedResultOfServiceCategoryDto": { "allOf": [ { diff --git a/dev/shared-working-context/backend/STATUS.md b/dev/shared-working-context/backend/STATUS.md index 231f68e..407e7ed 100644 --- a/dev/shared-working-context/backend/STATUS.md +++ b/dev/shared-working-context/backend/STATUS.md @@ -12,6 +12,34 @@ One block per completed backend phase. Newest at the top. Backend lane writes he - **Notes for frontend:** --> +## backend-phase-8 — Booking requests (pre-payment intent) — 2026-07-06 +- **Shipped:** the money-free request lifecycle via one additive migration — new **`booking`** schema, + **1 table** `BookingRequests`. The customer-side (`CreateBookingRequest` — tenancy invariant + patient+address∈customer & variant∈nurse, bookability + **same-gender** match, response deadline frozen from + `nurse_response_deadline_hours`; `CancelBookingRequest`), the nurse-side (`AcceptBookingRequest` — freezes + `payment_deadline_at` from `booking_payment_deadline_minutes`=30, self-guards the response deadline; + `RejectBookingRequest` — reason required), the reads (`ListBookingRequests` role-scoped inbox + + `GetBookingRequest` party/admin, **nurse view masks the full address, exposes only `customer_notes`**), and + the **forward-only status guard** (`BookingRequestTransitions`) + the idempotent, paginated + `ExpireBookingRequests` sweep behind a recurring `BackgroundService` (`BookingRequestExpiryHostedService`, + reuses the b1 `IJobScheduler` seam) + an admin manual trigger. **2 controllers:** `BookingRequestsController` + (customer/nurse) + `AdminBookingRequestsController` (`POST admin_booking_requests/expire`). Inbox/expiry + covering indexes `(nurse_id,status)`/`(customer_id,status)`/`(status,response_deadline)`/`(status,payment_deadline)`. + **No money column, no `bookings` row, no snapshot, no price** anywhere. +- **Contracts:** dev/contracts/domains/booking-requests.md + openapi snapshot refreshed (yes — the 7 booking + paths + `BookingRequestDto`/`BookingRequestListItemDto`/`ExpireBookingRequestsResult`). +- **Mocked:** **nothing new** — reuses `IPlatformConfig`/`INotificationDispatcher`/`IJobScheduler`/ + `IDateTimeProvider`/`ICurrentUser`. **No new `mocks-registry.md` row** (the `IJobScheduler` row was updated to + note the second hosted job). +- **Gate:** build clean (0 new code warnings) / tests green (215 pass: +20 booking = 14 handler-unit + the + transition machine + 4 DB-backed SQLite + 5 API integration). Migration applied to the dev DB; API boots with + all booking paths in Swagger; the expiry sweep runs on startup (verified against SQL Server). +- **Handoff:** backend/handoff/after-backend-phase-8.md +- **Notes for frontend:** f7-b8 = the request form (C4 → `booking_requests/create`), the status tracker (C5 → + `booking_requests/get/{id}` + customer inbox with the response + 30-min payment countdowns + `cancel`), and + the nurse inbox (`booking_requests/list?role=nurse` + `accept`/`reject`). Gender required (`male`/`female`/ + `any`); deadlines are server-frozen UTC timestamps; the nurse sees only `customer_notes` + a masked address. + ## backend-phase-7 — Search & matching (nurse search index) — 2026-07-05 - **Shipped:** the discovery layer via one additive migration — new **`search`** schema, **1 table** `NurseSearchIndices` (the denormalized `nurse_search_index`): **one flat row per (bookable variant × diff --git a/dev/shared-working-context/backend/handoff/after-backend-phase-8.md b/dev/shared-working-context/backend/handoff/after-backend-phase-8.md new file mode 100644 index 0000000..c871de0 --- /dev/null +++ b/dev/shared-working-context/backend/handoff/after-backend-phase-8.md @@ -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. diff --git a/dev/shared-working-context/reports/backend-phase-8-report.md b/dev/shared-working-context/reports/backend-phase-8-report.md new file mode 100644 index 0000000..5b6fb7c --- /dev/null +++ b/dev/shared-working-context/reports/backend-phase-8-report.md @@ -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. diff --git a/dev/shared-working-context/reports/mocks-registry.md b/dev/shared-working-context/reports/mocks-registry.md index bfc6167..c399b7b 100644 --- a/dev/shared-working-context/reports/mocks-registry.md +++ b/dev/shared-working-context/reports/mocks-registry.md @@ -22,7 +22,7 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢 | `IBankTransferProvider` | backend-phase-13 | PAYA/SATNA payout — fake transfer ref | _tbd_ | Jibit/Vandar/Sadad payout; source account; PAYA vs SATNA | 🔴 | | `IHolidayCalendar` | backend-phase-1 | Bank holidays — reads the seeded `ops.IranianHolidays` table; lookups cached (`HolidayCalendarService`, `Persistence/Services/Holidays/`); Iranian banking weekend = Friday | _none_ | Add a sync job/feed that maintains the (partly lunar-Hijri) calendar table; the read interface stays | 🟡 | | `IAnalyticsSink` | backend-phase-1 | Behavioural events — inserts an `ops.SystemEvents` row, fire-and-forget (`AnalyticsSink`, `Persistence/Services/Analytics/`) | _none_ | Pipe to a warehouse/stream (e.g. Kafka→ClickHouse); keep fire-and-forget semantics | 🟡 | -| `IJobScheduler` (retention) | backend-phase-1 | Scheduling — in-process interval `BackgroundService` running `PurgeOldReadNotifications` daily (`NotificationRetentionHostedService`, `Persistence/Services/Notifications/`) | _none_ | Swap to Hangfire/Quartz; register the job there; keep the purge predicate (`is_read=1 AND age>90d`) | 🟡 | +| `IJobScheduler` (retention + booking expiry) | backend-phase-1 | Scheduling — in-process interval `BackgroundService`s: `PurgeOldReadNotifications` daily (`NotificationRetentionHostedService`, `Persistence/Services/Notifications/`) and **b8** `BookingRequestExpiryHostedService` (`Persistence/Services/Booking/`) running the idempotent booking-request expiry sweep every minute | _none_ | Swap to Hangfire/Quartz; register **both** jobs there; keep the purge predicate (`is_read=1 AND age>90d`) and the booking-expiry command | 🟡 | | `IShahkarVerifier` | backend-phase-6 | شاهکار phone↔national-id binding — `MockShahkarVerifier` (`Baya.Infrastructure.CrossCutting/Seams/`) returns a deterministic result + fake vendor ref + `external_response_json`: matches every pair except the configured shared-SIM phone (→ the explicit shared-SIM failure state, which the handler turns into a `shared_sim` support alert) and the mismatch national id (→ plain mismatch); registered singleton in `AddCrossCuttingSeams`. No real Shahkar call | `Seams:Shahkar:SharedSimPhone` (default `09120000000`), `Seams:Shahkar:MismatchNationalId` (default `1111111111`) | 1) pick a Finnotech / KYC Shahkar-bridge vendor, add its client package to `Directory.Packages.props`; 2) add `Seams:Shahkar:{ApiKey,BaseUrl}` options; 3) implement `MatchAsync(phone, nationalId)` against the real استعلام شاهکار, mapping to `ShahkarMatchResult` and persisting the raw response into the step's `external_response_json`; 4) keep shared-SIM as the explicit handled failure (`IsSharedSim=true`); 5) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 6) test match / shared-SIM / mismatch + that a phone change re-runs it (`shahkar_verified_at` resets upstream on phone change) | 🟡 | | `IIdentityKycProvider` | backend-phase-6 | Identity KYC (national-id validity + name match + liveness) — `MockIdentityKycProvider` (`.../Seams/`) passes any well-formed 10-digit national id except the configured fail id, returning a matched name + fake vendor ref + `external_response_json`; on pass the handler populates `users.national_id` + `national_id_verified_at`. No real OCR/liveness; registered singleton | `Seams:IdentityKyc:FailNationalId` (default `0000000000`), `Seams:IdentityKyc:MatchedName` (default `Verified Nurse`) | 1) pick an Iranian e-KYC vendor (Finnotech / U-ID / Jibbit / Farashensa / Verify / Kavoshak), add its client package to `Directory.Packages.props`; 2) add `Seams:IdentityKyc:{ApiKey,BaseUrl}` options; 3) implement `VerifyAsync(nationalId, livenessPayload)` → national-id validity + name match + photo/video liveness against ثبت احوال, mapping to `IdentityKycResult` and persisting `external_response_json`; 4) swap the registration (config-selected) — handlers unchanged; 5) test pass/fail by national id + that `national_id` is populated **only** on pass | 🟡 | | `ICredentialVerifier` | backend-phase-6 | MoH پروانه صلاحیت حرفه‌ای / INO / عدم سوء پیشینه verification — `MockCredentialVerifier` (`.../Seams/`) **is** the manual-admin default: every call returns `RequiresManualReview` with `verification_method=manual` (an admin verifies the uploaded document against the official portal in `AdminReviewStep`). No portal call; registered singleton. There is **no public B2B API** for MoH/INO, so this stays manual until one appears | _none_ | 1) when an MoH/INO portal or API becomes available, implement `VerifyAsync(credentialType, credentialNumber)` to return `Verified`/`Failed` with `verification_method=portal|api` (+ `external_response_json`); 2) swap the registration (config-selected) for those credential types — the manual path stays the fallback; 3) the structured `nurse_credentials` registry already stores number/authority/expiry so cross-check + renewal survive the swap. **MoH/INO have no public B2B API today** | 🟡 | diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 3fe0bff..e3d1ac5 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -81,15 +81,15 @@ projects/assemblies, Clean-Architecture layers, and cross-layer dependencies. ``` src/ ├── Core/ -│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker) -│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); Search area = the b7 discovery query + admin index-rebuild; + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers) +│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), Booking/ (BookingRequest — the money-free pre-payment intent + BookingRequestStatus/BookingRequestTransitions forward-only status guard + CaregiverGender codes), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker) +│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); Search area = the b7 discovery query + admin index-rebuild; Booking area = the b8 booking-request lifecycle (create/accept/reject/cancel + role-scoped inbox/detail + the expiry sweep command); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers) ├── Infrastructure/ -│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service + Search/ = SearchIndexMaintainer + SqlNurseSearch) +│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/ + BookingConfig/), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service + Search/ = SearchIndexMaintainer + SqlNurseSearch + Booking/ = BookingRequestExpiryHostedService) │ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/) │ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier + MockShahkarVerifier + MockIdentityKycProvider + MockCredentialVerifier) + AddCrossCuttingSeams │ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net ├── API/ -│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch), appsettings*.json +│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch + customer/nurse BookingRequests + admin AdminBookingRequests), appsettings*.json │ ├── Baya.WebFramework BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting) │ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models (User only) ├── Shared/Baya.SharedKernel Extensions + validation base @@ -226,6 +226,31 @@ Two seams live in `Application/Contracts/Search/`, registered by `AddPersistence row on re-upsert so each (variant × area) has exactly one live row. - **Incremental maintenance and full rebuild must converge** — the index is fully re-derivable from source. +**Booking requests — pre-payment intent (backend-phase-8).** A new **`booking` schema** holds the single +table `BookingRequests` — the **money-free** first half of the engagement lifecycle (`bookings` + money are +b9/b10). One customer requests one nurse for a patient/variant/address/date; the nurse accepts (opening a +30-minute payment window) or rejects before a frozen response deadline; unanswered/unpaid requests auto-expire. +Features under `Baya.Application/Features/Booking/{Commands|Queries}/`; config in +`Persistence/Configuration/BookingConfig/`; per-domain repo (`IBookingRequestRepository`) on `IUnitOfWork`; +the recurring sweep is `Persistence/Services/Booking/BookingRequestExpiryHostedService` (reuses the b1 +`IJobScheduler`/`BackgroundService` seam). Load-bearing rules: +- **No money, ever, and no `bookings` row.** A request carries no price/total; accept only opens the payment + window. b9 consumes an `accepted_awaiting_payment` request → creates the booking → sets it `converted`. +- **Two-stage clinical disclosure (stage 1).** The nurse sees **only** the unencrypted, limited `customer_notes` + (never routed through `IFieldEncryptor`); the nurse view of a request **masks the full address** (line/postal/ + recipient) to a coarse city/district. The encrypted `booking_care_instructions` are b9's stage 2. +- **Tenancy invariant.** patient + address ∈ the caller's `customer_id`; variant ∈ the requested `nurse_id`. + Resolved from `ICurrentUser`, never the body; a mismatch is a clean 404. +- **Same-gender match at request time.** `required_caregiver_gender` (`male`/`female`/`any`) is matched against + the nurse's `User.Gender`; required on create, never silently defaulted. +- **Deadlines frozen from config.** `nurse_response_deadline_at` = `now + nurse_response_deadline_hours` at + create; `payment_deadline_at` = `now + booking_payment_deadline_minutes` (30) at accept — both stored as + absolute UTC `datetime2` so a later config change can't move them. Stored as `DateTime` (not `DateTimeOffset`) + because they are compared/sorted in queries and the SQLite test provider can't translate `DateTimeOffset`. +- **Forward-only status guard** (`BookingRequestTransitions`) — every write is pre-checked; an illegal edge is a + 409, terminal states have no outgoing edge; the expiry sweep's `WHERE status = …` predicate is the concurrency + guard (a row a racing accept/cancel moved is simply not reloaded). See CONVENTIONS §6. + **Keeping the Project map current.** When a change touches the architecture — adds, removes, or renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer dependency — you **must** update this Project map (and the dependency rule above, if affected) in the diff --git a/server/CONVENTIONS.md b/server/CONVENTIONS.md index ba64b6f..970d8e8 100644 --- a/server/CONVENTIONS.md +++ b/server/CONVENTIONS.md @@ -332,6 +332,30 @@ rules this establishes: and the wire carry `in_review`, not `InReview`. Enum→code mapping in a projected read happens **in memory after materialization** (`.ToCode()` is not LINQ-translatable); DTOs expose the code string. +### Forward-only status machine (backend-phase-8) + +When an entity has a lifecycle `status` with a fixed set of allowed transitions, model the machine as a +**static allowed-edges table** and route **every** write through it — never assign `status` ad-hoc. The b8 +pattern (reused by b9 for the `bookings` machine): + +- **Statuses are `const string` codes** (`BookingRequestStatus`) persisted as the stable snake_case string — + no C# enum, no value converter needed. **Edges live in a static `CanTransition(from, to)`** + (`BookingRequestTransitions`) built from a `Dictionary>`; 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 diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/AdminBookingRequestsController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/AdminBookingRequestsController.cs new file mode 100644 index 0000000..d45dd84 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/AdminBookingRequestsController.cs @@ -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; + +/// +/// Admin manual trigger for the booking-request expiry sweep — fires the same idempotent command the +/// recurring BackgroundService runs, so a human/test can expire stale requests on demand without +/// waiting for the interval. +/// +[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] + public async Task Expire(CancellationToken cancellationToken) + => OperationResult(await sender.Send(new ExpireBookingRequestsCommand(), cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/BookingRequestsController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/BookingRequestsController.cs new file mode 100644 index 0000000..25a050f --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/BookingRequestsController.cs @@ -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; + +/// +/// 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 +/// bookings row exist here — accept only opens the 30-minute payment window (converted in b9). +/// +[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] + public async Task Create(CreateBookingRequestCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpPost("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task Cancel(long id, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new CancelBookingRequestCommand(id), cancellationToken)); + + [HttpPost("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task Accept(long id, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new AcceptBookingRequestCommand(id), cancellationToken)); + + [HttpPost("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task Reject(long id, RejectBookingRequestCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command with { Id = id }, cancellationToken)); + + [HttpGet("[action]")] + [ProducesOkApiResponseType>] + public async Task List([FromQuery] ListBookingRequestsQuery query, CancellationToken cancellationToken) + => OperationResult(await sender.Send(query, cancellationToken)); + + [HttpGet("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task Get(long id, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new GetBookingRequestQuery(id), cancellationToken)); +} diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IBookingRequestRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IBookingRequestRepository.cs new file mode 100644 index 0000000..c1aa610 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IBookingRequestRepository.cs @@ -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; + +/// +/// The pre-payment booking_requests 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 bookings row exist anywhere here. +/// +public interface IBookingRequestRepository +{ + Task AddAsync(BookingRequest request, CancellationToken cancellationToken); + + /// Tracked, customer-owned lookup for cancel. NULL if not owned/absent (existence not leaked). + Task GetTrackedForCustomerAsync(long id, long customerId, CancellationToken cancellationToken); + + /// Tracked, nurse-assigned lookup for accept/reject, with the Customer navigation loaded + /// so the handler can notify the customer's user id. NULL if not the caller's/absent. + Task GetTrackedForNurseAsync(long id, long nurseId, CancellationToken cancellationToken); + + /// Tracked pending_nurse_response rows whose response deadline has passed (with + /// Customer loaded), capped at — one bounded page of the expiry sweep. + Task> GetStalePendingAsync(DateTime now, int batchSize, CancellationToken cancellationToken); + + /// Tracked accepted_awaiting_payment rows whose payment window has lapsed (with + /// Customer loaded), capped at . + Task> GetStaleAcceptedAsync(DateTime now, int batchSize, CancellationToken cancellationToken); + + /// The customer's own inbox — counterparty is the nurse (name + rating), actionable first. + Task> ListForCustomerAsync(long customerId, string? status, int page, int pageSize, CancellationToken cancellationToken); + + /// The nurse's inbox — counterparty is the patient; exposes stage-1 customer_notes only. + Task> ListForNurseAsync(long nurseId, string? status, int page, int pageSize, CancellationToken cancellationToken); + + /// Role-agnostic detail projection (carries both party ids for authorization + the full address + /// for the customer/admin path). NULL when absent. + Task GetDetailAsync(long id, CancellationToken cancellationToken); +} diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/INurseProfileRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/INurseProfileRepository.cs index 38de340..65e5c8a 100644 --- a/server/src/Core/Baya.Application/Contracts/Persistence/INurseProfileRepository.cs +++ b/server/src/Core/Baya.Application/Contracts/Persistence/INurseProfileRepository.cs @@ -26,4 +26,9 @@ public interface INurseProfileRepository /// The nurse's nurse_profiles.id + decrypted national id — what the bank-account /// ownership inquiry needs. NULL when the user has no nurse profile yet. Task GetIdentityContextByUserIdAsync(int userId, CancellationToken cancellationToken); + + /// 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. + Task GetBookingContextByIdAsync(long nurseProfileId, CancellationToken cancellationToken); } diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs index 61bb44c..a0866b0 100644 --- a/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs @@ -15,6 +15,7 @@ public interface IUnitOfWork public ICatalogRepository CatalogRepository { get; } public INurseServiceVariantRepository NurseServiceVariantRepository { get; } public IVerificationRepository VerificationRepository { get; } + public IBookingRequestRepository BookingRequestRepository { get; } Task CommitAsync(); ValueTask RollBackAsync(); } diff --git a/server/src/Core/Baya.Application/Features/Booking/BookingRequestMapper.cs b/server/src/Core/Baya.Application/Features/Booking/BookingRequestMapper.cs new file mode 100644 index 0000000..2c6770f --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Booking/BookingRequestMapper.cs @@ -0,0 +1,47 @@ +#nullable enable +using Baya.Application.Models.Booking; + +namespace Baya.Application.Features.Booking; + +/// +/// Maps the role-agnostic detail projection to the wire DTO. 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 customer_notes, never the encrypted address line. +/// +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); +} diff --git a/server/src/Core/Baya.Application/Features/Booking/Commands/AcceptBookingRequest/AcceptBookingRequestCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Booking/Commands/AcceptBookingRequest/AcceptBookingRequestCommand.Handler.cs new file mode 100644 index 0000000..0e1fa59 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Booking/Commands/AcceptBookingRequest/AcceptBookingRequestCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(AcceptBookingRequestCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + if (currentUser.Roles?.Contains(RoleNames.Nurse) != true) + return OperationResult.ForbiddenResult("Only a nurse can accept a booking request."); + + var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (nurseId is not { } nid) + return OperationResult.ForbiddenResult("No nurse profile exists yet."); + + var bookingRequest = await unitOfWork.BookingRequestRepository.GetTrackedForNurseAsync(request.Id, nid, cancellationToken); + if (bookingRequest is null) + return OperationResult.NotFoundResult("Booking request not found."); + + if (!bookingRequest.CanTransitionTo(BookingRequestStatus.AcceptedAwaitingPayment)) + return OperationResult.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.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("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.SuccessResult(BookingRequestMapper.ToDto(detail!, includeFullAddress: false)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Booking/Commands/AcceptBookingRequest/AcceptBookingRequestCommand.cs b/server/src/Core/Baya.Application/Features/Booking/Commands/AcceptBookingRequest/AcceptBookingRequestCommand.cs new file mode 100644 index 0000000..4a0c069 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Booking/Commands/AcceptBookingRequest/AcceptBookingRequestCommand.cs @@ -0,0 +1,10 @@ +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Booking.Commands.AcceptBookingRequest; + +/// The assigned nurse accepts a pending request, opening the config-driven 30-minute payment +/// window. Id comes from the route. No booking and no money are created — accept only opens the +/// window. +public record AcceptBookingRequestCommand(long Id = 0) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Booking/Commands/CancelBookingRequest/CancelBookingRequestCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Booking/Commands/CancelBookingRequest/CancelBookingRequestCommand.Handler.cs new file mode 100644 index 0000000..cc389fb --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Booking/Commands/CancelBookingRequest/CancelBookingRequestCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(CancelBookingRequestCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + if (currentUser.Roles?.Contains(RoleNames.Customer) != true) + return OperationResult.ForbiddenResult("Only a customer can cancel a booking request."); + + var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (customerId is not { } cid) + return OperationResult.ForbiddenResult("No customer profile exists yet."); + + var bookingRequest = await unitOfWork.BookingRequestRepository.GetTrackedForCustomerAsync(request.Id, cid, cancellationToken); + if (bookingRequest is null) + return OperationResult.NotFoundResult("Booking request not found."); + + if (!bookingRequest.CanTransitionTo(BookingRequestStatus.CancelledByCustomer)) + return OperationResult.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.SuccessResult(BookingRequestMapper.ToDto(detail!, includeFullAddress: true)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Booking/Commands/CancelBookingRequest/CancelBookingRequestCommand.cs b/server/src/Core/Baya.Application/Features/Booking/Commands/CancelBookingRequest/CancelBookingRequestCommand.cs new file mode 100644 index 0000000..5cdad15 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Booking/Commands/CancelBookingRequest/CancelBookingRequestCommand.cs @@ -0,0 +1,10 @@ +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Booking.Commands.CancelBookingRequest; + +/// The customer withdraws a request that is still pending or accepted-awaiting-payment (before they +/// pay). Id comes from the route. This is a request cancellation only — a booking cancellation with +/// refund tiers is b9+/DEFERRED. +public record CancelBookingRequestCommand(long Id = 0) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Booking/Commands/CreateBookingRequest/CreateBookingRequestCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Booking/Commands/CreateBookingRequest/CreateBookingRequestCommand.Handler.cs new file mode 100644 index 0000000..b2707f8 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Booking/Commands/CreateBookingRequest/CreateBookingRequestCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(CreateBookingRequestCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + if (currentUser.Roles?.Contains(RoleNames.Customer) != true) + return OperationResult.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.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.NotFoundResult("Patient not found."); + + var address = await unitOfWork.CustomerAddressRepository.GetOwnedAsync(request.CustomerAddressId, cid, cancellationToken); + if (address is null) + return OperationResult.NotFoundResult("Address not found."); + + var variant = await unitOfWork.NurseServiceVariantRepository.GetOwnedAsync(request.VariantId, request.NurseId, cancellationToken); + if (variant is null) + return OperationResult.NotFoundResult("Service variant not found for this nurse."); + + if (!variant.IsActive) + return OperationResult.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.NotFoundResult("Nurse not found."); + + if (!nurse.IsVerified || !nurse.IsAcceptingBookings) + return OperationResult.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.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.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("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.SuccessResult(BookingRequestMapper.ToDto(detail!, includeFullAddress: true)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Booking/Commands/CreateBookingRequest/CreateBookingRequestCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Booking/Commands/CreateBookingRequest/CreateBookingRequestCommand.Validator.cs new file mode 100644 index 0000000..5ae1746 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Booking/Commands/CreateBookingRequest/CreateBookingRequestCommand.Validator.cs @@ -0,0 +1,29 @@ +using Baya.Domain.Entities.Booking; +using FluentValidation; + +namespace Baya.Application.Features.Booking.Commands.CreateBookingRequest; + +public sealed class CreateBookingRequestCommandValidator : AbstractValidator +{ + 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. + } +} diff --git a/server/src/Core/Baya.Application/Features/Booking/Commands/CreateBookingRequest/CreateBookingRequestCommand.cs b/server/src/Core/Baya.Application/Features/Booking/Commands/CreateBookingRequest/CreateBookingRequestCommand.cs new file mode 100644 index 0000000..3350aa8 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Booking/Commands/CreateBookingRequest/CreateBookingRequestCommand.cs @@ -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; + +/// +/// A customer requests a specific nurse for a patient, service variant, address, date and time, with a +/// required caregiver gender and optional stage-1 customer_notes. 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 pending_nurse_response request. +/// +public record CreateBookingRequestCommand( + long NurseId, + long VariantId, + long PatientId, + long CustomerAddressId, + DateOnly RequestedDate, + TimeOnly RequestedTimeStart, + TimeOnly RequestedTimeEnd, + string RequiredCaregiverGender, + string? CustomerNotes) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Booking/Commands/ExpireBookingRequests/ExpireBookingRequestsCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Booking/Commands/ExpireBookingRequests/ExpireBookingRequestsCommand.Handler.cs new file mode 100644 index 0000000..219c534 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Booking/Commands/ExpireBookingRequests/ExpireBookingRequestsCommand.Handler.cs @@ -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> +{ + private const int BatchSize = 100; + + public async ValueTask> 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.SuccessResult( + new ExpireBookingRequestsResult(expiredNoResponse, paymentExpired)); + } + + private async Task SweepAsync( + Func>> loadStale, + string targetStatus, + Action 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(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; + } +} diff --git a/server/src/Core/Baya.Application/Features/Booking/Commands/ExpireBookingRequests/ExpireBookingRequestsCommand.cs b/server/src/Core/Baya.Application/Features/Booking/Commands/ExpireBookingRequests/ExpireBookingRequestsCommand.cs new file mode 100644 index 0000000..6489d3a --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Booking/Commands/ExpireBookingRequests/ExpireBookingRequestsCommand.cs @@ -0,0 +1,14 @@ +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Booking.Commands.ExpireBookingRequests; + +/// +/// Transitions stale requests: pending_nurse_response → expired_no_response once the response +/// deadline passes, and accepted_awaiting_payment → payment_deadline_expired once the payment window +/// lapses. Runs on an interval via a hosted BackgroundService 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). +/// +public record ExpireBookingRequestsCommand : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Booking/Commands/RejectBookingRequest/RejectBookingRequestCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Booking/Commands/RejectBookingRequest/RejectBookingRequestCommand.Handler.cs new file mode 100644 index 0000000..e0684bd --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Booking/Commands/RejectBookingRequest/RejectBookingRequestCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(RejectBookingRequestCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + if (currentUser.Roles?.Contains(RoleNames.Nurse) != true) + return OperationResult.ForbiddenResult("Only a nurse can reject a booking request."); + + var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (nurseId is not { } nid) + return OperationResult.ForbiddenResult("No nurse profile exists yet."); + + var bookingRequest = await unitOfWork.BookingRequestRepository.GetTrackedForNurseAsync(request.Id, nid, cancellationToken); + if (bookingRequest is null) + return OperationResult.NotFoundResult("Booking request not found."); + + if (!bookingRequest.CanTransitionTo(BookingRequestStatus.RejectedByNurse)) + return OperationResult.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.SuccessResult(BookingRequestMapper.ToDto(detail!, includeFullAddress: false)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Booking/Commands/RejectBookingRequest/RejectBookingRequestCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Booking/Commands/RejectBookingRequest/RejectBookingRequestCommand.Validator.cs new file mode 100644 index 0000000..9c8ce2b --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Booking/Commands/RejectBookingRequest/RejectBookingRequestCommand.Validator.cs @@ -0,0 +1,14 @@ +using FluentValidation; + +namespace Baya.Application.Features.Booking.Commands.RejectBookingRequest; + +public sealed class RejectBookingRequestCommandValidator : AbstractValidator +{ + public RejectBookingRequestCommandValidator() + { + // Id is route-supplied — not validated here (see FluentValidation activation note in server CLAUDE.md). + RuleFor(x => x.Reason) + .NotEmpty() + .MaximumLength(500); + } +} diff --git a/server/src/Core/Baya.Application/Features/Booking/Commands/RejectBookingRequest/RejectBookingRequestCommand.cs b/server/src/Core/Baya.Application/Features/Booking/Commands/RejectBookingRequest/RejectBookingRequestCommand.cs new file mode 100644 index 0000000..dcb3c8d --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Booking/Commands/RejectBookingRequest/RejectBookingRequestCommand.cs @@ -0,0 +1,9 @@ +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Booking.Commands.RejectBookingRequest; + +/// The assigned nurse declines a pending request with a required reason. Id comes from the +/// route; only Reason is body input. +public record RejectBookingRequestCommand(string Reason, long Id = 0) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Booking/Queries/GetBookingRequest/GetBookingRequestQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Booking/Queries/GetBookingRequest/GetBookingRequestQuery.Handler.cs new file mode 100644 index 0000000..9f630ec --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Booking/Queries/GetBookingRequest/GetBookingRequestQuery.Handler.cs @@ -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> +{ + private static readonly string[] AdminRoles = + [RoleNames.Admin, RoleNames.SuperAdmin, RoleNames.Support, RoleNames.Finance, RoleNames.Moderation]; + + public async ValueTask> Handle(GetBookingRequestQuery request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var detail = await unitOfWork.BookingRequestRepository.GetDetailAsync(request.Id, cancellationToken); + if (detail is null) + return OperationResult.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.SuccessResult(BookingRequestMapper.ToDto(detail, includeFullAddress: true)); + + var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (nurseId == detail.NurseId) + return OperationResult.SuccessResult(BookingRequestMapper.ToDto(detail, includeFullAddress: false)); + + if (isAdmin) + return OperationResult.SuccessResult(BookingRequestMapper.ToDto(detail, includeFullAddress: true)); + + // Neither party nor admin — do not leak that the request exists. + return OperationResult.NotFoundResult("Booking request not found."); + } +} diff --git a/server/src/Core/Baya.Application/Features/Booking/Queries/GetBookingRequest/GetBookingRequestQuery.cs b/server/src/Core/Baya.Application/Features/Booking/Queries/GetBookingRequest/GetBookingRequestQuery.cs new file mode 100644 index 0000000..e222dff --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Booking/Queries/GetBookingRequest/GetBookingRequestQuery.cs @@ -0,0 +1,9 @@ +using Baya.Application.Models.Booking; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Booking.Queries.GetBookingRequest; + +/// 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. +public record GetBookingRequestQuery(long Id) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Booking/Queries/ListBookingRequests/ListBookingRequestsQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Booking/Queries/ListBookingRequests/ListBookingRequestsQuery.Handler.cs new file mode 100644 index 0000000..ee4bb5c --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Booking/Queries/ListBookingRequests/ListBookingRequestsQuery.Handler.cs @@ -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>> +{ + public async ValueTask>> Handle(ListBookingRequestsQuery request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult>.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>.SuccessResult( + new PagedResult([], 0, page, pageSize)); + + return OperationResult>.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>.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 + }; + } +} diff --git a/server/src/Core/Baya.Application/Features/Booking/Queries/ListBookingRequests/ListBookingRequestsQuery.cs b/server/src/Core/Baya.Application/Features/Booking/Queries/ListBookingRequests/ListBookingRequestsQuery.cs new file mode 100644 index 0000000..f40bcff --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Booking/Queries/ListBookingRequests/ListBookingRequestsQuery.cs @@ -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; + +/// +/// The role-scoped inbox: a customer sees their own requests, a nurse sees requests addressed to them. +/// Role disambiguates a user who holds both roles (customer/nurse); when omitted it is +/// inferred from which profile the caller has. Optional Status filter; actionable rows sort first. +/// +public record ListBookingRequestsQuery( + string? Status = null, + string? Role = null, + int Page = 1, + int PageSize = 50) : IRequest>>; diff --git a/server/src/Core/Baya.Application/Models/Booking/BookingRequestDetailProjection.cs b/server/src/Core/Baya.Application/Models/Booking/BookingRequestDetailProjection.cs new file mode 100644 index 0000000..a38f95e --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Booking/BookingRequestDetailProjection.cs @@ -0,0 +1,44 @@ +#nullable enable +namespace Baya.Application.Models.Booking; + +/// +/// 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 mask them for the nurse. The +/// handler maps this to the public ; the encrypted fields never leave the +/// customer/admin path. +/// +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); diff --git a/server/src/Core/Baya.Application/Models/Booking/BookingRequestDto.cs b/server/src/Core/Baya.Application/Models/Booking/BookingRequestDto.cs new file mode 100644 index 0000000..540d219 --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Booking/BookingRequestDto.cs @@ -0,0 +1,42 @@ +#nullable enable +namespace Baya.Application.Models.Booking; + +/// +/// The full single-request view returned by create/accept/reject/cancel and the detail query. The +/// nurse view masks the encrypted full address (// +/// recipient) — stage-1 disclosure surfaces only plus a coarse city/district +/// location. The customer (and admin) view returns their own full address. +/// +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); diff --git a/server/src/Core/Baya.Application/Models/Booking/BookingRequestListItemDto.cs b/server/src/Core/Baya.Application/Models/Booking/BookingRequestListItemDto.cs new file mode 100644 index 0000000..c7a50bc --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Booking/BookingRequestListItemDto.cs @@ -0,0 +1,21 @@ +#nullable enable +namespace Baya.Application.Models.Booking; + +/// +/// One row in the role-scoped inbox. is the nurse's name in the customer +/// inbox and the patient's name in the nurse inbox; is populated only in the +/// customer inbox, and (stage-1 disclosure) only in the nurse inbox. No +/// encrypted/clinical field is ever projected here. +/// +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); diff --git a/server/src/Core/Baya.Application/Models/Booking/ExpireBookingRequestsResult.cs b/server/src/Core/Baya.Application/Models/Booking/ExpireBookingRequestsResult.cs new file mode 100644 index 0000000..79d554d --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Booking/ExpireBookingRequestsResult.cs @@ -0,0 +1,5 @@ +namespace Baya.Application.Models.Booking; + +/// 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). +public record ExpireBookingRequestsResult(int ExpiredNoResponse, int PaymentDeadlineExpired); diff --git a/server/src/Core/Baya.Application/Models/Identity/NurseBookingContext.cs b/server/src/Core/Baya.Application/Models/Identity/NurseBookingContext.cs new file mode 100644 index 0000000..a83a0b3 --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Identity/NurseBookingContext.cs @@ -0,0 +1,13 @@ +#nullable enable +namespace Baya.Application.Models.Identity; + +/// +/// The facts a booking-request create needs about the target nurse in one read: their +/// (for the request-received notification), their (for the same-gender match), and the +/// two bookability gates (, ). +/// +public record NurseBookingContext( + int UserId, + string? Gender, + bool IsVerified, + bool IsAcceptingBookings); diff --git a/server/src/Core/Baya.Domain/Entities/Booking/BookingRequest.cs b/server/src/Core/Baya.Domain/Entities/Booking/BookingRequest.cs new file mode 100644 index 0000000..608c591 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Booking/BookingRequest.cs @@ -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; + +/// +/// A customer's pre-payment intent for a specific nurse, patient, service variant, address, date and +/// time. It is deliberately money-free and separate from bookings (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 before accepting — stage 1 of the two-stage clinical +/// disclosure boundary (the full encrypted care instructions are b9's, post-confirmation). +/// +/// Both deadlines are computed once from config and frozen on the row, so a later config change can +/// never move an existing request's deadlines. Status changes only through the forward-only +/// guard. +/// +/// +public class BookingRequest : BaseEntity +{ + 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!; + + /// Closed code set — see . Matched against the nurse's gender at + /// request time; a first-class filter, never a soft preference. + public string? RequiredCaregiverGender { get; set; } + + public DateOnly RequestedDate { get; set; } + public TimeOnly RequestedTimeStart { get; set; } + public TimeOnly RequestedTimeEnd { get; set; } + + /// Unencrypted, request-stage only — the ONLY clinical context the nurse sees before + /// accepting (Principle 6, stage 1). Never routed through the field encryptor; deliberately limited. + public string? CustomerNotes { get; set; } + + /// Guarded — mutated only through the transition methods so every write goes through the + /// forward-only status machine. + public string Status { get; private set; } = BookingRequestStatus.PendingNurseResponse; + + /// UTC datetime2, frozen at create time (now + nurse_response_deadline_hours), + /// immune to later config changes. After it passes an unanswered request auto-expires. Stored as + /// (not ) because it is compared/sorted in queries and + /// the SQLite test provider cannot translate DateTimeOffset operators. + public DateTime NurseResponseDeadlineAt { get; set; } + + /// Null until accept; then frozen to now + booking_payment_deadline_minutes (= 30). UTC. + 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); + + /// Nurse accepts — opens the (already-computed) 30-minute payment window. No booking, no money. + 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); + + /// Marks the request converted — called only by b9 when it creates the bookings row. + 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; + } +} diff --git a/server/src/Core/Baya.Domain/Entities/Booking/BookingRequestStatus.cs b/server/src/Core/Baya.Domain/Entities/Booking/BookingRequestStatus.cs new file mode 100644 index 0000000..80f9612 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Booking/BookingRequestStatus.cs @@ -0,0 +1,30 @@ +namespace Baya.Domain.Entities.Booking; + +/// +/// The closed status vocabulary of a — the pre-payment 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 . +/// +public static class BookingRequestStatus +{ + /// Awaiting the nurse's accept/reject before nurse_response_deadline_at. + public const string PendingNurseResponse = "pending_nurse_response"; + + /// Nurse accepted; the 30-minute payment_deadline_at window is open. No money yet. + public const string AcceptedAwaitingPayment = "accepted_awaiting_payment"; + + /// A booking was created from this request (set by b9 on payment capture). Terminal. + public const string Converted = "converted"; + + /// Nurse declined with a reason. Terminal. + public const string RejectedByNurse = "rejected_by_nurse"; + + /// The nurse never responded before the deadline (set by the expiry sweep). Terminal. + public const string ExpiredNoResponse = "expired_no_response"; + + /// The 30-minute payment window lapsed unpaid (set by the expiry sweep). Terminal. + public const string PaymentDeadlineExpired = "payment_deadline_expired"; + + /// The customer withdrew the request before paying. Terminal. + public const string CancelledByCustomer = "cancelled_by_customer"; +} diff --git a/server/src/Core/Baya.Domain/Entities/Booking/BookingRequestTransitions.cs b/server/src/Core/Baya.Domain/Entities/Booking/BookingRequestTransitions.cs new file mode 100644 index 0000000..14ed085 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Booking/BookingRequestTransitions.cs @@ -0,0 +1,36 @@ +namespace Baya.Domain.Entities.Booking; + +/// +/// The forward-only status machine for . 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 bookings machine. +/// +public static class BookingRequestTransitions +{ + private static readonly IReadOnlyDictionary> Allowed = + new Dictionary> + { + [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); +} diff --git a/server/src/Core/Baya.Domain/Entities/Booking/CaregiverGender.cs b/server/src/Core/Baya.Domain/Entities/Booking/CaregiverGender.cs new file mode 100644 index 0000000..f0ca414 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Booking/CaregiverGender.cs @@ -0,0 +1,23 @@ +namespace Baya.Domain.Entities.Booking; + +/// +/// The closed code set for required_caregiver_gender. 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. matches either nurse gender. +/// +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; + + /// + /// True when a nurse of satisfies the required caregiver gender. + /// always matches; / require an exact match. + /// + public static bool Matches(string required, string nurseGender) + => required == Any || string.Equals(required, nurseGender, System.StringComparison.OrdinalIgnoreCase); +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/BookingConfig/BookingRequestConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/BookingConfig/BookingRequestConfig.cs new file mode 100644 index 0000000..b94296e --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/BookingConfig/BookingRequestConfig.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260705225226_BookingRequests.Designer.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260705225226_BookingRequests.Designer.cs new file mode 100644 index 0000000..decea6a --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260705225226_BookingRequests.Designer.cs @@ -0,0 +1,3660 @@ +// +using System; +using Baya.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Baya.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260705225226_BookingRequests")] + partial class BookingRequests + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("PropsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("UserId"); + + b.ToTable("SystemEvents", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ActorUserId") + .HasColumnType("int"); + + b.Property("ChangedFieldsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("AuditLogs", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerAddressId") + .HasColumnType("bigint"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("CustomerNotes") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("NurseRejectionReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NurseResponseDeadlineAt") + .HasColumnType("datetime2"); + + b.Property("PatientId") + .HasColumnType("bigint"); + + b.Property("PaymentDeadlineAt") + .HasColumnType("datetime2"); + + b.Property("RequestedDate") + .HasColumnType("date"); + + b.Property("RequestedTimeEnd") + .HasColumnType("time"); + + b.Property("RequestedTimeStart") + .HasColumnType("time"); + + b.Property("RequiredCaregiverGender") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CustomerAddressId"); + + b.HasIndex("PatientId"); + + b.HasIndex("VariantId"); + + b.HasIndex("CustomerId", "Status"); + + b.HasIndex("NurseId", "Status"); + + b.HasIndex("Status", "NurseResponseDeadlineAt"); + + b.HasIndex("Status", "PaymentDeadlineAt"); + + b.ToTable("BookingRequests", "booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("OptionSetHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Price") + .HasColumnType("bigint"); + + b.Property("PriceUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ServiceCategoryId") + .HasColumnType("bigint"); + + b.Property("SessionCount") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ServiceCategoryId"); + + b.HasIndex("NurseId", "IsActive"); + + b.HasIndex("NurseId", "ServiceCategoryId", "OptionSetHash") + .IsUnique() + .HasDatabaseName("UX_NurseServiceVariants_Nurse_Category_OptionSet") + .HasFilter("[DeletedAt] IS NULL"); + + b.ToTable("NurseServiceVariants", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariantOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OptionGroupId") + .HasColumnType("bigint"); + + b.Property("OptionValueId") + .HasColumnType("bigint"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("OptionGroupId"); + + b.HasIndex("OptionValueId"); + + b.HasIndex("VariantId", "OptionGroupId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceVariantOptions_Variant_Group"); + + b.ToTable("NurseServiceVariantOptions", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DescriptionEn") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DescriptionFa") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IconKey") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("IsActive", "SortOrder"); + + b.ToTable("ServiceCategories", "catalog"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Elderly Care", + NameFa = "مراقبت از سالمند", + SortOrder = 1 + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Post-Surgery Recovery", + NameFa = "مراقبت پس از جراحی", + SortOrder = 2 + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Infant Care", + NameFa = "مراقبت از نوزاد", + SortOrder = 3 + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Chronic Illness Management", + NameFa = "مدیریت بیماری مزمن", + SortOrder = 4 + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Companionship", + NameFa = "همراهی و مراقبت روزمره", + SortOrder = 5 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("IsRequired") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ServiceCategoryId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("ServiceCategoryId", "SortOrder"); + + b.ToTable("ServiceOptionGroups", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionValue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("OptionGroupId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("OptionGroupId", "SortOrder"); + + b.ToTable("ServiceOptionValues", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Configuration.PlatformConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("PlatformConfigs", "ops"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "Balinyaar commission rate on the booking gross (fraction).", + Key = "platform_fee_rate", + Value = "0.15" + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "VAT rate applied to the commission line only (fraction).", + Key = "vat_rate", + Value = "0.10" + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours after check-out a booking can be disputed.", + Key = "dispute_window_hours", + Value = "72" + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Minutes a family has to pay before a pending booking expires.", + Key = "booking_payment_deadline_minutes", + Value = "30" + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours a nurse has to accept/decline a booking request.", + Key = "nurse_response_deadline_hours", + Value = "24" + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Weekly payout cadence in days.", + Key = "nurse_payout_interval_days", + Value = "7" + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Allowed EVV check-in distance from the care address.", + Key = "evv_location_tolerance_meters", + Value = "200" + }, + new + { + Id = 8L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "A review at or below this rating raises a support alert.", + Key = "min_rating_for_support_alert", + Value = "2" + }, + new + { + Id = 9L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "string", + Description = "Who is merchant of record for BNPL orders (platform|nurse).", + Key = "bnpl_merchant_of_record", + Value = "platform" + }, + new + { + Id = 10L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "BNPL provider commission rate (fraction).", + Key = "bnpl_provider_commission_rate", + Value = "0.07" + }, + new + { + Id = 11L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "string", + Description = "When BNPL settles funds to the platform (immediate|deferred).", + Key = "bnpl_settlement_timing", + Value = "immediate" + }, + new + { + Id = 12L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "json", + Description = "Tiered cancellation refund policy: refund_percent by hours before the visit.", + Key = "cancellation_tiers", + Value = "[{\"min_hours_before\":48,\"refund_percent\":100},{\"min_hours_before\":24,\"refund_percent\":50},{\"min_hours_before\":0,\"refund_percent\":0}]" + }, + new + { + Id = 13L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Seconds a phone must wait before another OTP can be requested.", + Key = "auth_otp_resend_seconds", + Value = "120" + }, + new + { + Id = 14L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Wrong-code attempts allowed before OTP verification is refused until a fresh code.", + Key = "auth_otp_max_attempts", + Value = "5" + }, + new + { + Id = 15L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Refresh-token session lifetime in days.", + Key = "auth_session_ttl_days", + Value = "30" + }, + new + { + Id = 16L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours between credential-expiry scans (the scheduled cron is deferred; the scan is admin-triggered today).", + Key = "verification_expiry_scan_cadence_hours", + Value = "24" + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ProvinceId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("ProvinceId", "SortOrder"); + + b.ToTable("Cities", "geo"); + + b.HasData( + new + { + Id = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tehran", + NameFa = "تهران", + ProvinceId = 1L, + SortOrder = 1 + }, + new + { + Id = 102L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Karaj", + NameFa = "کرج", + ProvinceId = 2L, + SortOrder = 1 + }, + new + { + Id = 103L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Isfahan", + NameFa = "اصفهان", + ProvinceId = 3L, + SortOrder = 1 + }, + new + { + Id = 104L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Shiraz", + NameFa = "شیراز", + ProvinceId = 4L, + SortOrder = 1 + }, + new + { + Id = 105L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Mashhad", + NameFa = "مشهد", + ProvinceId = 5L, + SortOrder = 1 + }, + new + { + Id = 106L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tabriz", + NameFa = "تبریز", + ProvinceId = 6L, + SortOrder = 1 + }, + new + { + Id = 107L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Urmia", + NameFa = "ارومیه", + ProvinceId = 7L, + SortOrder = 1 + }, + new + { + Id = 108L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ahvaz", + NameFa = "اهواز", + ProvinceId = 8L, + SortOrder = 1 + }, + new + { + Id = 109L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qom", + NameFa = "قم", + ProvinceId = 9L, + SortOrder = 1 + }, + new + { + Id = 110L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kerman", + NameFa = "کرمان", + ProvinceId = 10L, + SortOrder = 1 + }, + new + { + Id = 111L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Rasht", + NameFa = "رشت", + ProvinceId = 11L, + SortOrder = 1 + }, + new + { + Id = 112L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sari", + NameFa = "ساری", + ProvinceId = 12L, + SortOrder = 1 + }, + new + { + Id = 113L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Arak", + NameFa = "اراک", + ProvinceId = 13L, + SortOrder = 1 + }, + new + { + Id = 114L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ardabil", + NameFa = "اردبیل", + ProvinceId = 14L, + SortOrder = 1 + }, + new + { + Id = 115L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qazvin", + NameFa = "قزوین", + ProvinceId = 15L, + SortOrder = 1 + }, + new + { + Id = 116L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kermanshah", + NameFa = "کرمانشاه", + ProvinceId = 16L, + SortOrder = 1 + }, + new + { + Id = 117L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bojnord", + NameFa = "بجنورد", + ProvinceId = 17L, + SortOrder = 1 + }, + new + { + Id = 118L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Birjand", + NameFa = "بیرجند", + ProvinceId = 18L, + SortOrder = 1 + }, + new + { + Id = 119L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hamadan", + NameFa = "همدان", + ProvinceId = 19L, + SortOrder = 1 + }, + new + { + Id = 120L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sanandaj", + NameFa = "سنندج", + ProvinceId = 20L, + SortOrder = 1 + }, + new + { + Id = 121L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Khorramabad", + NameFa = "خرم‌آباد", + ProvinceId = 21L, + SortOrder = 1 + }, + new + { + Id = 122L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Gorgan", + NameFa = "گرگان", + ProvinceId = 22L, + SortOrder = 1 + }, + new + { + Id = 123L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bandar Abbas", + NameFa = "بندرعباس", + ProvinceId = 23L, + SortOrder = 1 + }, + new + { + Id = 124L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bushehr", + NameFa = "بوشهر", + ProvinceId = 24L, + SortOrder = 1 + }, + new + { + Id = 125L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zanjan", + NameFa = "زنجان", + ProvinceId = 25L, + SortOrder = 1 + }, + new + { + Id = 126L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Semnan", + NameFa = "سمنان", + ProvinceId = 26L, + SortOrder = 1 + }, + new + { + Id = 127L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yazd", + NameFa = "یزد", + ProvinceId = 27L, + SortOrder = 1 + }, + new + { + Id = 128L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zahedan", + NameFa = "زاهدان", + ProvinceId = 28L, + SortOrder = 1 + }, + new + { + Id = 129L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Shahrekord", + NameFa = "شهرکرد", + ProvinceId = 29L, + SortOrder = 1 + }, + new + { + Id = 130L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yasuj", + NameFa = "یاسوج", + ProvinceId = 30L, + SortOrder = 1 + }, + new + { + Id = 131L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ilam", + NameFa = "ایلام", + ProvinceId = 31L, + SortOrder = 1 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.District", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("CityId", "SortOrder"); + + b.ToTable("Districts", "geo"); + + b.HasData( + new + { + Id = 1001L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 1", + NameFa = "منطقه ۱", + SortOrder = 1 + }, + new + { + Id = 1002L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 2", + NameFa = "منطقه ۲", + SortOrder = 2 + }, + new + { + Id = 1003L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 3", + NameFa = "منطقه ۳", + SortOrder = 3 + }, + new + { + Id = 1004L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 4", + NameFa = "منطقه ۴", + SortOrder = 4 + }, + new + { + Id = 1005L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 5", + NameFa = "منطقه ۵", + SortOrder = 5 + }, + new + { + Id = 1006L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 6", + NameFa = "منطقه ۶", + SortOrder = 6 + }, + new + { + Id = 1007L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 7", + NameFa = "منطقه ۷", + SortOrder = 7 + }, + new + { + Id = 1008L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 8", + NameFa = "منطقه ۸", + SortOrder = 8 + }, + new + { + Id = 1009L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 9", + NameFa = "منطقه ۹", + SortOrder = 9 + }, + new + { + Id = 1010L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 10", + NameFa = "منطقه ۱۰", + SortOrder = 10 + }, + new + { + Id = 1011L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 11", + NameFa = "منطقه ۱۱", + SortOrder = 11 + }, + new + { + Id = 1012L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 12", + NameFa = "منطقه ۱۲", + SortOrder = 12 + }, + new + { + Id = 1013L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 13", + NameFa = "منطقه ۱۳", + SortOrder = 13 + }, + new + { + Id = 1014L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 14", + NameFa = "منطقه ۱۴", + SortOrder = 14 + }, + new + { + Id = 1015L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 15", + NameFa = "منطقه ۱۵", + SortOrder = 15 + }, + new + { + Id = 1016L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 16", + NameFa = "منطقه ۱۶", + SortOrder = 16 + }, + new + { + Id = 1017L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 17", + NameFa = "منطقه ۱۷", + SortOrder = 17 + }, + new + { + Id = 1018L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 18", + NameFa = "منطقه ۱۸", + SortOrder = 18 + }, + new + { + Id = 1019L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 19", + NameFa = "منطقه ۱۹", + SortOrder = 19 + }, + new + { + Id = 1020L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 20", + NameFa = "منطقه ۲۰", + SortOrder = 20 + }, + new + { + Id = 1021L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 21", + NameFa = "منطقه ۲۱", + SortOrder = 21 + }, + new + { + Id = 1022L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 22", + NameFa = "منطقه ۲۲", + SortOrder = 22 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.NurseServiceArea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DistrictId") + .HasColumnType("bigint"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("DistrictId"); + + b.HasIndex("NurseId", "CityId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceAreas_Nurse_City_WholeCity") + .HasFilter("[DistrictId] IS NULL AND [DeletedAt] IS NULL"); + + b.HasIndex("NurseId", "CityId", "DistrictId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceAreas_Nurse_City_District") + .HasFilter("[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL"); + + b.ToTable("NurseServiceAreas", "geo"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.Province", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("Provinces", "geo"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tehran", + NameFa = "تهران", + SortOrder = 1 + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Alborz", + NameFa = "البرز", + SortOrder = 2 + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Isfahan", + NameFa = "اصفهان", + SortOrder = 3 + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Fars", + NameFa = "فارس", + SortOrder = 4 + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Razavi Khorasan", + NameFa = "خراسان رضوی", + SortOrder = 5 + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "East Azerbaijan", + NameFa = "آذربایجان شرقی", + SortOrder = 6 + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "West Azerbaijan", + NameFa = "آذربایجان غربی", + SortOrder = 7 + }, + new + { + Id = 8L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Khuzestan", + NameFa = "خوزستان", + SortOrder = 8 + }, + new + { + Id = 9L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qom", + NameFa = "قم", + SortOrder = 9 + }, + new + { + Id = 10L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kerman", + NameFa = "کرمان", + SortOrder = 10 + }, + new + { + Id = 11L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Gilan", + NameFa = "گیلان", + SortOrder = 11 + }, + new + { + Id = 12L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Mazandaran", + NameFa = "مازندران", + SortOrder = 12 + }, + new + { + Id = 13L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Markazi", + NameFa = "مرکزی", + SortOrder = 13 + }, + new + { + Id = 14L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ardabil", + NameFa = "اردبیل", + SortOrder = 14 + }, + new + { + Id = 15L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qazvin", + NameFa = "قزوین", + SortOrder = 15 + }, + new + { + Id = 16L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kermanshah", + NameFa = "کرمانشاه", + SortOrder = 16 + }, + new + { + Id = 17L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "North Khorasan", + NameFa = "خراسان شمالی", + SortOrder = 17 + }, + new + { + Id = 18L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "South Khorasan", + NameFa = "خراسان جنوبی", + SortOrder = 18 + }, + new + { + Id = 19L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hamadan", + NameFa = "همدان", + SortOrder = 19 + }, + new + { + Id = 20L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kurdistan", + NameFa = "کردستان", + SortOrder = 20 + }, + new + { + Id = 21L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Lorestan", + NameFa = "لرستان", + SortOrder = 21 + }, + new + { + Id = 22L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Golestan", + NameFa = "گلستان", + SortOrder = 22 + }, + new + { + Id = 23L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hormozgan", + NameFa = "هرمزگان", + SortOrder = 23 + }, + new + { + Id = 24L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bushehr", + NameFa = "بوشهر", + SortOrder = 24 + }, + new + { + Id = 25L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zanjan", + NameFa = "زنجان", + SortOrder = 25 + }, + new + { + Id = 26L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Semnan", + NameFa = "سمنان", + SortOrder = 26 + }, + new + { + Id = 27L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yazd", + NameFa = "یزد", + SortOrder = 27 + }, + new + { + Id = 28L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sistan and Baluchestan", + NameFa = "سیستان و بلوچستان", + SortOrder = 28 + }, + new + { + Id = 29L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Chaharmahal and Bakhtiari", + NameFa = "چهارمحال و بختیاری", + SortOrder = 29 + }, + new + { + Id = 30L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kohgiluyeh and Boyer-Ahmad", + NameFa = "کهگیلویه و بویراحمد", + SortOrder = 30 + }, + new + { + Id = 31L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ilam", + NameFa = "ایلام", + SortOrder = 31 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Holidays.IranianHoliday", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("HolidayDate") + .HasColumnType("date"); + + b.Property("IsBankClosed") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("HolidayDate") + .IsUnique(); + + b.ToTable("IranianHolidays", "ops"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 2, 11), + IsBankClosed = true, + NameFa = "پیروزی انقلاب اسلامی", + Type = "national" + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 21), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 22), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 23), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 24), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 4, 1), + IsBankClosed = true, + NameFa = "روز طبیعت (سیزده‌به‌در)", + Type = "official" + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 6, 26), + IsBankClosed = true, + NameFa = "عید سعید قربان", + Type = "religious" + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AddressLine") + .HasColumnType("nvarchar(max)"); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DistrictId") + .HasColumnType("bigint"); + + b.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("Latitude") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("Longitude") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PostalCode") + .HasColumnType("nvarchar(max)"); + + b.Property("RecipientName") + .HasColumnType("nvarchar(max)"); + + b.Property("RecipientPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("CustomerId") + .IsUnique() + .HasDatabaseName("UX_CustomerAddresses_Customer_Primary") + .HasFilter("[IsPrimary] = 1 AND [DeletedAt] IS NULL"); + + b.HasIndex("DistrictId"); + + b.ToTable("CustomerAddresses", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DefaultEmergencyContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("DefaultEmergencyContactPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("CustomerProfiles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseBankAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountHolderFromBank") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("AccountHolderName") + .HasColumnType("nvarchar(max)"); + + b.Property("BankName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Iban") + .HasColumnType("nvarchar(max)"); + + b.Property("IbanHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsVerified") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("MatchedNationalId") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("OwnershipVendorRef") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("VerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("VerifiedByAdminId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("IbanHash") + .IsUnique(); + + b.HasIndex("NurseId") + .IsUnique() + .HasDatabaseName("UX_NurseBankAccounts_NurseId_Primary") + .HasFilter("[IsPrimary] = 1"); + + b.HasIndex("VerifiedByAdminId"); + + b.ToTable("NurseBankAccounts", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AverageRating") + .ValueGeneratedOnAdd() + .HasPrecision(3, 2) + .HasColumnType("decimal(3,2)") + .HasDefaultValue(0m); + + b.Property("Bio") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("EducationField") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("EducationLevel") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsAcceptingBookings") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsVerified") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PartnerCenterId") + .HasColumnType("bigint"); + + b.Property("SpecializationsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("TotalCompletedBookings") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("TotalReviews") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("YearsOfExperience") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("NurseProfiles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.Patient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BirthDate") + .HasColumnType("date"); + + b.Property("BloodType") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("FirstName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Gender") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("InitialMedicalNotes") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("LastName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.ToTable("Patients", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Body") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DataJson") + .HasColumnType("nvarchar(max)"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ReadAt") + .HasColumnType("datetimeoffset"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsRead", "CreatedAt"); + + b.ToTable("Notifications", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AverageRating") + .HasPrecision(3, 2) + .HasColumnType("decimal(3,2)"); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DistrictId") + .HasColumnType("bigint"); + + b.Property("IsSearchable") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseGender") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("Price") + .HasColumnType("bigint"); + + b.Property("PriceUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ServiceCategoryId") + .HasColumnType("bigint"); + + b.Property("TotalCompletedBookings") + .HasColumnType("int"); + + b.Property("TotalReviews") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("NurseId"); + + b.HasIndex("VariantId", "CityId") + .IsUnique() + .HasDatabaseName("UX_NurseSearchIndex_Variant_City_WholeCity") + .HasFilter("[DistrictId] IS NULL AND [DeletedAt] IS NULL"); + + b.HasIndex("VariantId", "CityId", "DistrictId") + .IsUnique() + .HasDatabaseName("UX_NurseSearchIndex_Variant_City_District") + .HasFilter("[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL"); + + b.HasIndex("IsSearchable", "ServiceCategoryId", "CityId", "DistrictId") + .HasDatabaseName("IX_NurseSearchIndex_Search"); + + SqlServerIndexBuilderExtensions.IncludeProperties(b.HasIndex("IsSearchable", "ServiceCategoryId", "CityId", "DistrictId"), new[] { "Price", "NurseGender", "AverageRating", "TotalReviews", "NurseId", "VariantId" }); + + b.ToTable("NurseSearchIndices", "search"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OwnerUserId") + .HasColumnType("int"); + + b.Property("ResolutionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("ResolvedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ReviewId") + .HasColumnType("bigint"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("Status"); + + b.HasIndex("Type"); + + b.ToTable("SupportAlerts", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Roles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.RoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedClaim") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("UserId"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FamilyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("GeneratedCode") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalId") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalIdVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("PhoneVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("ShahkarVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.HasIndex("PhoneHash") + .IsUnique() + .HasFilter("[PhoneHash] IS NOT NULL"); + + b.ToTable("Users", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("LoggedOn") + .HasColumnType("datetime2"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("IsValid") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserRefreshTokens", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRole", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.Property("CreatedUserRoleDate") + .HasColumnType("datetime2"); + + b.Property("GrantedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GrantedById") + .HasColumnType("int"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("GrantedById"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeviceInfo") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsRevoked") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("RefreshTokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RefreshTokenHash") + .IsUnique(); + + b.HasIndex("UserId", "IsRevoked"); + + b.ToTable("UserSessions", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserToken", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("GeneratedTime") + .HasColumnType("datetime2"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CredentialNumber") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("CredentialType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExpiresAt") + .HasColumnType("date"); + + b.Property("HolderNameSnapshot") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IssuedAt") + .HasColumnType("date"); + + b.Property("IssuingAuthority") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("VerificationMethod") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VerificationSource") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("VerifiedByAdminId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("VerifiedByAdminId"); + + b.HasIndex("NurseId", "CredentialType"); + + b.ToTable("NurseCredentials", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApprovedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("InternalNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("RejectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RejectionReason") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ReviewedByAdminId") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SubmittedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SuspendedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("NurseId") + .IsUnique(); + + b.HasIndex("ReviewedByAdminId"); + + b.ToTable("NurseVerifications", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("IntegrityHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("ObjectStorageKey") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("OriginalFileName") + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("StepId") + .HasColumnType("bigint"); + + b.Property("UploadedByUserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StepId"); + + b.HasIndex("UploadedByUserId"); + + b.ToTable("VerificationDocuments", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalResponseJson") + .HasColumnType("nvarchar(max)"); + + b.Property("FailureReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsAutomated") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseVerificationId") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("StepTypeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("StepTypeId"); + + b.HasIndex("NurseVerificationId", "StepTypeId") + .IsUnique() + .HasDatabaseName("UX_VerificationSteps_Verification_StepType"); + + b.ToTable("VerificationSteps", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStepType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AutomationProvider") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("IsAutomated") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsRequired") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive", "SortOrder"); + + b.ToTable("VerificationStepTypes", "verif"); + + b.HasData( + new + { + Id = 1L, + AutomationProvider = "identity_kyc_vendor", + Code = "identity_kyc", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "National-ID validity, name match and photo/video liveness via an Iranian e-KYC vendor.", + DisplayName = "Identity Verification (KYC)", + IsActive = true, + IsAutomated = true, + IsRequired = true, + SortOrder = 1 + }, + new + { + Id = 2L, + AutomationProvider = "shahkar", + Code = "shahkar_match", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "Confirms the login SIM is registered to the nurse's own national ID (شاهکار).", + DisplayName = "Shahkar Phone Binding", + IsActive = true, + IsAutomated = true, + IsRequired = true, + SortOrder = 2 + }, + new + { + Id = 3L, + Code = "moh_competency_license", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "پروانه صلاحیت حرفه‌ای — the MoH-mandated in-home nursing licence (bundles the criminal-record screen). Manual today.", + DisplayName = "MoH Professional Competency License", + IsActive = true, + IsAutomated = false, + IsRequired = true, + SortOrder = 3 + }, + new + { + Id = 4L, + Code = "ino_membership", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "نظام پرستاری membership cross-check (ino.ir). Manual today.", + DisplayName = "Nursing Organization (INO) Membership", + IsActive = true, + IsAutomated = false, + IsRequired = true, + SortOrder = 4 + }, + new + { + Id = 5L, + Code = "criminal_record", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "عدم سوء پیشینه — consent-gated, nurse-uploaded, time-limited (reverts on expiry).", + DisplayName = "Criminal Record Certificate", + IsActive = true, + IsAutomated = false, + IsRequired = true, + SortOrder = 5 + }, + new + { + Id = 6L, + AutomationProvider = "sheba", + Code = "bank_account_verification", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "استعلام شبا — the payout IBAN owner's national ID must equal the verified nurse national ID.", + DisplayName = "Bank Account (IBAN) Ownership", + IsActive = true, + IsAutomated = true, + IsRequired = true, + SortOrder = 6 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Audit.AuditLog", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("ActorUserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingRequest", b => + { + b.HasOne("Baya.Domain.Entities.Identity.CustomerAddress", "CustomerAddress") + .WithMany() + .HasForeignKey("CustomerAddressId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant") + .WithMany() + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("CustomerAddress"); + + b.Navigation("Nurse"); + + b.Navigation("Patient"); + + b.Navigation("Variant"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.ServiceCategory", "ServiceCategory") + .WithMany("Variants") + .HasForeignKey("ServiceCategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Nurse"); + + b.Navigation("ServiceCategory"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariantOption", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionGroup", "OptionGroup") + .WithMany() + .HasForeignKey("OptionGroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionValue", "OptionValue") + .WithMany() + .HasForeignKey("OptionValueId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant") + .WithMany("Options") + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OptionGroup"); + + b.Navigation("OptionValue"); + + b.Navigation("Variant"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceCategory", "ServiceCategory") + .WithMany("OptionGroups") + .HasForeignKey("ServiceCategoryId"); + + b.Navigation("ServiceCategory"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionValue", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionGroup", "OptionGroup") + .WithMany("Values") + .HasForeignKey("OptionGroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OptionGroup"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.HasOne("Baya.Domain.Entities.Geography.Province", "Province") + .WithMany("Cities") + .HasForeignKey("ProvinceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Province"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.District", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany("Districts") + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("City"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.NurseServiceArea", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany() + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Geography.District", "District") + .WithMany() + .HasForeignKey("DistrictId"); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("City"); + + b.Navigation("District"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerAddress", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany() + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Geography.District", "District") + .WithMany() + .HasForeignKey("DistrictId"); + + b.Navigation("City"); + + b.Navigation("Customer"); + + b.Navigation("District"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithOne() + .HasForeignKey("Baya.Domain.Entities.Identity.CustomerProfile", "UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseBankAccount", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany("BankAccounts") + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("VerifiedByAdminId"); + + b.Navigation("Nurse"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithOne() + .HasForeignKey("Baya.Domain.Entities.Identity.NurseProfile", "UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.Patient", b => + { + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer") + .WithMany("Patients") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant") + .WithMany() + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Variant"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("OwnerUserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.RoleClaim", b => + { + b.HasOne("Baya.Domain.Entities.User.Role", "Role") + .WithMany("Claims") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserClaim", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Claims") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserLogin", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Logins") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRefreshToken", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("UserRefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRole", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("GrantedById"); + + b.HasOne("Baya.Domain.Entities.User.Role", "Role") + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserSession", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Sessions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserToken", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Tokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseCredential", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("VerifiedByAdminId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("ReviewedByAdminId"); + + b.Navigation("Nurse"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationDocument", b => + { + b.HasOne("Baya.Domain.Entities.Verification.VerificationStep", "Step") + .WithMany("Documents") + .HasForeignKey("StepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UploadedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Step"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b => + { + b.HasOne("Baya.Domain.Entities.Verification.NurseVerification", "NurseVerification") + .WithMany("Steps") + .HasForeignKey("NurseVerificationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Verification.VerificationStepType", "StepType") + .WithMany("Steps") + .HasForeignKey("StepTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("NurseVerification"); + + b.Navigation("StepType"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b => + { + b.Navigation("Options"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceCategory", b => + { + b.Navigation("OptionGroups"); + + b.Navigation("Variants"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.Navigation("Values"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.Navigation("Districts"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.Province", b => + { + b.Navigation("Cities"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b => + { + b.Navigation("Patients"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b => + { + b.Navigation("BankAccounts"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.Role", b => + { + b.Navigation("Claims"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.User", b => + { + b.Navigation("Claims"); + + b.Navigation("Logins"); + + b.Navigation("Sessions"); + + b.Navigation("Tokens"); + + b.Navigation("UserRefreshTokens"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b => + { + b.Navigation("Documents"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStepType", b => + { + b.Navigation("Steps"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260705225226_BookingRequests.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260705225226_BookingRequests.cs new file mode 100644 index 0000000..1d14978 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260705225226_BookingRequests.cs @@ -0,0 +1,135 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Baya.Infrastructure.Persistence.Migrations +{ + /// + public partial class BookingRequests : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "booking"); + + migrationBuilder.CreateTable( + name: "BookingRequests", + schema: "booking", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + CustomerId = table.Column(type: "bigint", nullable: false), + NurseId = table.Column(type: "bigint", nullable: false), + PatientId = table.Column(type: "bigint", nullable: false), + VariantId = table.Column(type: "bigint", nullable: false), + CustomerAddressId = table.Column(type: "bigint", nullable: false), + RequiredCaregiverGender = table.Column(type: "nvarchar(10)", maxLength: 10, nullable: true), + RequestedDate = table.Column(type: "date", nullable: false), + RequestedTimeStart = table.Column(type: "time", nullable: false), + RequestedTimeEnd = table.Column(type: "time", nullable: false), + CustomerNotes = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: true), + Status = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + NurseResponseDeadlineAt = table.Column(type: "datetime2", nullable: false), + PaymentDeadlineAt = table.Column(type: "datetime2", nullable: true), + NurseRejectionReason = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + DeletedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + ModifiedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedById = table.Column(type: "int", nullable: true), + ModifiedById = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "BookingRequests", + schema: "booking"); + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs index c106f36..e3ea472 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -98,6 +98,95 @@ namespace Baya.Infrastructure.Persistence.Migrations b.ToTable("AuditLogs", "ops"); }); + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerAddressId") + .HasColumnType("bigint"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("CustomerNotes") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("NurseRejectionReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NurseResponseDeadlineAt") + .HasColumnType("datetime2"); + + b.Property("PatientId") + .HasColumnType("bigint"); + + b.Property("PaymentDeadlineAt") + .HasColumnType("datetime2"); + + b.Property("RequestedDate") + .HasColumnType("date"); + + b.Property("RequestedTimeEnd") + .HasColumnType("time"); + + b.Property("RequestedTimeStart") + .HasColumnType("time"); + + b.Property("RequiredCaregiverGender") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CustomerAddressId"); + + b.HasIndex("PatientId"); + + b.HasIndex("VariantId"); + + b.HasIndex("CustomerId", "Status"); + + b.HasIndex("NurseId", "Status"); + + b.HasIndex("Status", "NurseResponseDeadlineAt"); + + b.HasIndex("Status", "PaymentDeadlineAt"); + + b.ToTable("BookingRequests", "booking"); + }); + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b => { b.Property("Id") @@ -3076,6 +3165,49 @@ namespace Baya.Infrastructure.Persistence.Migrations .HasForeignKey("ActorUserId"); }); + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingRequest", b => + { + b.HasOne("Baya.Domain.Entities.Identity.CustomerAddress", "CustomerAddress") + .WithMany() + .HasForeignKey("CustomerAddressId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant") + .WithMany() + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("CustomerAddress"); + + b.Navigation("Nurse"); + + b.Navigation("Patient"); + + b.Navigation("Variant"); + }); + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b => { b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/BookingRequestRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/BookingRequestRepository.cs new file mode 100644 index 0000000..ccc2caf --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/BookingRequestRepository.cs @@ -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, IBookingRequestRepository +{ + public BookingRequestRepository(ApplicationDbContext dbContext) : base(dbContext) + { + } + + public Task AddAsync(BookingRequest request, CancellationToken cancellationToken) + => base.AddAsync(request); + + public Task GetTrackedForCustomerAsync(long id, long customerId, CancellationToken cancellationToken) + => Table.FirstOrDefaultAsync(r => r.Id == id && r.CustomerId == customerId, cancellationToken); + + public Task GetTrackedForNurseAsync(long id, long nurseId, CancellationToken cancellationToken) + => Table + .Include(r => r.Customer) + .FirstOrDefaultAsync(r => r.Id == id && r.NurseId == nurseId, cancellationToken); + + public async Task> 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> 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> 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(items, total, page, pageSize); + } + + public async Task> 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(items, total, page, pageSize); + } + + public async Task 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 OrderActionableFirst(IQueryable 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); +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs index e25700c..d0c3e4b 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs @@ -19,6 +19,7 @@ public class UnitOfWork : IUnitOfWork public ICatalogRepository CatalogRepository { get; } public INurseServiceVariantRepository NurseServiceVariantRepository { get; } public IVerificationRepository VerificationRepository { get; } + public IBookingRequestRepository BookingRequestRepository { get; } public UnitOfWork(ApplicationDbContext db) { @@ -36,6 +37,7 @@ public class UnitOfWork : IUnitOfWork CatalogRepository = new CatalogRepository(_db); NurseServiceVariantRepository = new NurseServiceVariantRepository(_db); VerificationRepository = new VerificationRepository(_db); + BookingRequestRepository = new BookingRequestRepository(_db); } public Task CommitAsync() diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/NurseProfileRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/NurseProfileRepository.cs index 0ca8557..5bd3a7b 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/NurseProfileRepository.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/NurseProfileRepository.cs @@ -49,4 +49,10 @@ internal sealed class NurseProfileRepository : BaseAsyncRepository .Where(p => p.UserId == userId) .Select(p => new NurseIdentityContext(p.Id, p.User.NationalId)) .FirstOrDefaultAsync(cancellationToken); + + public Task 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); } diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs index c525f98..f0fb2aa 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs @@ -11,6 +11,7 @@ using Baya.Infrastructure.Persistence.Interceptors; using Baya.Infrastructure.Persistence.Repositories.Common; using Baya.Infrastructure.Persistence.Services.Analytics; using Baya.Infrastructure.Persistence.Services.Audit; +using Baya.Infrastructure.Persistence.Services.Booking; using Baya.Infrastructure.Persistence.Services.Configuration; using Baya.Infrastructure.Persistence.Services.Holidays; using Baya.Infrastructure.Persistence.Services.Notifications; @@ -53,6 +54,10 @@ public static class ServiceCollectionExtensions // Retention job seam (mock = in-process interval runner; real Hangfire/Quartz deferred). services.AddHostedService(); + // 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(); + // Search (backend-phase-7). The index maintainer keeps nurse_search_index consistent inline inside // each source write's unit of work. The INurseSearch backend is config-selected — SQL is the real // MVP backend; a later ElasticNurseSearch drops in here with no caller change. diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Booking/BookingRequestExpiryHostedService.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Booking/BookingRequestExpiryHostedService.cs new file mode 100644 index 0000000..0060bf9 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Booking/BookingRequestExpiryHostedService.cs @@ -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; + +/// +/// The recurring expiry sweep for booking_requests (reuses the b1 in-process interval-runner seam; +/// real Hangfire/Quartz is deferred). Each tick sends , which +/// transitions stale rows (expired_no_response / payment_deadline_expired) 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. +/// +internal sealed class BookingRequestExpiryHostedService( + IServiceScopeFactory scopeFactory, + ILogger 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(); + 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"); + } + } +} diff --git a/server/src/Tests/Baya.Test.Api/BookingRequestsApiTests.cs b/server/src/Tests/Baya.Test.Api/BookingRequestsApiTests.cs new file mode 100644 index 0000000..dd37cd4 --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/BookingRequestsApiTests.cs @@ -0,0 +1,78 @@ +using System.Net; +using System.Net.Http.Json; + +namespace Baya.Test.Api; + +public class BookingRequestsApiTests(BayaApiFactory factory) : IClassFixture +{ + 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()); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Booking/BookingRequestExpiryTests.cs b/server/src/Tests/Baya.Test.Foundation/Booking/BookingRequestExpiryTests.cs new file mode 100644 index 0000000..69fda82 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Booking/BookingRequestExpiryTests.cs @@ -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(); + private readonly INotificationDispatcher _notifications = Substitute.For(); + + 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(), Arg.Any()); + } + + [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(); +} diff --git a/server/src/Tests/Baya.Test.Foundation/Booking/BookingRequestQueryTests.cs b/server/src/Tests/Baya.Test.Foundation/Booking/BookingRequestQueryTests.cs new file mode 100644 index 0000000..957de93 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Booking/BookingRequestQueryTests.cs @@ -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(); + + 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(); +} diff --git a/server/src/Tests/Baya.Test.Foundation/Booking/BookingRequestTransitionsTests.cs b/server/src/Tests/Baya.Test.Foundation/Booking/BookingRequestTransitionsTests.cs new file mode 100644 index 0000000..9f0cedd --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Booking/BookingRequestTransitionsTests.cs @@ -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(() => request.Accept(DateTime.UnixEpoch)); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Booking/BookingTestHost.cs b/server/src/Tests/Baya.Test.Foundation/Booking/BookingTestHost.cs new file mode 100644 index 0000000..84909ed --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Booking/BookingTestHost.cs @@ -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; + +/// +/// 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 . +/// +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() + .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().Add(province); + Db.SaveChanges(); + + var city = new City { ProvinceId = province.Id, NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true }; + Db.Set().Add(city); + Db.SaveChanges(); + + var category = new ServiceCategory { NameFa = "سالمند", NameEn = "Elderly", SortOrder = 1, IsActive = true }; + Db.Set().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().Add(customer); + Db.SaveChanges(); + CustomerId = customer.Id; + + var patient = new Patient { CustomerId = customer.Id, DisplayName = "پدر", FirstName = "حسن", LastName = "رضایی", Gender = "male", IsActive = true }; + Db.Set().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().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().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().Add(variant); + Db.SaveChanges(); + VariantId = variant.Id; + } + + /// Inserts a pending request with the given response deadline (defaults far in the future). + 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().Add(request); + Db.SaveChanges(); + return request; + } + + /// Inserts an accepted request whose payment window closes at . + public BookingRequest AddAcceptedRequest(DateTime paymentDeadline) + { + var request = AddPendingRequest(); + request.Accept(paymentDeadline); + Db.SaveChanges(); + return request; + } + + public string StatusOf(long id) + => Db.Set().AsNoTracking().Where(r => r.Id == id).Select(r => r.Status).Single(); + + public void Dispose() + { + Db.Dispose(); + _connection.Dispose(); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Booking/CreateBookingRequestHandlerTests.cs b/server/src/Tests/Baya.Test.Foundation/Booking/CreateBookingRequestHandlerTests.cs new file mode 100644 index 0000000..f8af65b --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Booking/CreateBookingRequestHandlerTests.cs @@ -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(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly IPlatformConfig _config = Substitute.For(); + private readonly IDateTimeProvider _clock = Substitute.For(); + private readonly INotificationDispatcher _notifications = Substitute.For(); + + private readonly ICustomerProfileRepository _customers = Substitute.For(); + private readonly IPatientRepository _patients = Substitute.For(); + private readonly ICustomerAddressRepository _addresses = Substitute.For(); + private readonly INurseServiceVariantRepository _variants = Substitute.For(); + private readonly INurseProfileRepository _nurses = Substitute.For(); + private readonly IBookingRequestRepository _requests = Substitute.For(); + + 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()).Returns(CustomerId); + _patients.GetOwnedAsync(PatientId, CustomerId, Arg.Any()) + .Returns(new Patient { CustomerId = CustomerId, DisplayName = "پدر", Gender = "male" }); + _addresses.GetOwnedAsync(AddressId, CustomerId, Arg.Any()) + .Returns(new CustomerAddress { CustomerId = CustomerId }); + _variants.GetOwnedAsync(VariantId, NurseId, Arg.Any()) + .Returns(new NurseServiceVariant { NurseId = NurseId, IsActive = true }); + _nurses.GetBookingContextByIdAsync(NurseId, Arg.Any()) + .Returns(new NurseBookingContext(NurseUserId, "female", IsVerified: true, IsAcceptingBookings: true)); + _config.GetConfig("nurse_response_deadline_hours", Arg.Any()).Returns(24); + _requests.GetDetailAsync(Arg.Any(), Arg.Any()).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(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()); + await _unitOfWork.Received(1).CommitAsync(); + await _notifications.Received(1).DispatchAsync( + Arg.Is(n => n.RecipientUserId == NurseUserId && n.Type == "booking_request_received"), + Arg.Any()); + } + + [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(), Arg.Any()); + } + + [Fact] + public async Task Create_CrossCustomerPatient_IsNotFoundAndCreatesNothing() + { + _patients.GetOwnedAsync(PatientId, CustomerId, Arg.Any()).Returns((Patient)null); + + var result = await Handler().Handle(Command(), CancellationToken.None); + + Assert.True(result.IsNotFound); + await _requests.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Create_AddressNotOwned_IsNotFound() + { + _addresses.GetOwnedAsync(AddressId, CustomerId, Arg.Any()).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()).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()) + .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(), Arg.Any()); + } + + [Fact] + public async Task Create_NurseNotAcceptingOrUnverified_Fails() + { + _nurses.GetBookingContextByIdAsync(NurseId, Arg.Any()) + .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); +} diff --git a/server/src/Tests/Baya.Test.Foundation/Booking/RespondBookingRequestHandlerTests.cs b/server/src/Tests/Baya.Test.Foundation/Booking/RespondBookingRequestHandlerTests.cs new file mode 100644 index 0000000..3afdc6c --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Booking/RespondBookingRequestHandlerTests.cs @@ -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(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly IPlatformConfig _config = Substitute.For(); + private readonly IDateTimeProvider _clock = Substitute.For(); + private readonly INotificationDispatcher _notifications = Substitute.For(); + private readonly INurseProfileRepository _nurses = Substitute.For(); + private readonly ICustomerProfileRepository _customers = Substitute.For(); + private readonly IBookingRequestRepository _requests = Substitute.For(); + + 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()).Returns(NurseId); + _customers.GetProfileIdByUserIdAsync(CustomerUserId, Arg.Any()).Returns(CustomerId); + _config.GetConfig("booking_payment_deadline_minutes", Arg.Any()).Returns(30); + _requests.GetDetailAsync(Arg.Any(), Arg.Any()).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()).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(n => n.RecipientUserId == CustomerAccountUserId && n.Type == "booking_request_accepted"), + Arg.Any()); + } + + [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()).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()).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()).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()).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(n => n.Type == "booking_request_rejected"), Arg.Any()); + } + + [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()).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()).Returns(request); + _requests.GetDetailAsync(Arg.Any(), Arg.Any()) + .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()).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); +}