diff --git a/dev/contracts/domains/search.md b/dev/contracts/domains/search.md new file mode 100644 index 0000000..87e980b --- /dev/null +++ b/dev/contracts/domains/search.md @@ -0,0 +1,105 @@ +# Contract — Nurse search & matching (backend phase b7) + +> The single public nurse-discovery endpoint (category + city/district geo, same-gender filter, price range, +> rating sort, paginated) plus the admin search-index rebuild. Reads a denormalized, maintained-on-write +> projection and returns **only searchable (verified + accepting + not-suspended + active) nurses**. 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 b7). + +**Status:** live as of backend-phase-b7 · **Frontend consumer:** frontend-phase-f6-b7 + +> **Routing note.** Routes are **action-style** (`[controller]/[action]`, snake_cased). All responses use the +> standard `{ succeeded, statusCode, data }` envelope; `data` shapes are below. Query parameters are +> **snake_case** (`service_category_id`, `city_id`, …). + +## Key semantics (read first) +- **Only `is_searchable = 1` rows are ever returned.** A row is searchable **only** when the nurse is + `is_verified` AND not suspended AND `is_accepting_bookings` AND the variant `is_active`. An unverified, + paused, suspended, or deactivated nurse/variant never appears — this is the phase's highest-stakes rule. +- **The result unit is the variant, not the nurse.** Each hit is a bookable `nurse_service_variant` matched + in a covered area; a nurse with multiple variants/areas can appear as multiple hits. +- **`district_id = null` ⇒ whole city**, both directions: + - A **city-only** search (no `district_id`) matches every row in the city — both the whole-city (NULL) rows + and every district row. + - A **district** search matches that district's rows **plus** the whole-city (NULL) rows (a whole-city + nurse covers every district). +- **Same-gender matching is a first-class facet.** `nurse_gender` (`male`/`female`) is an up-front filter; + it is never silently defaulted or dropped. (Carrying the chosen gender *into* the booking request — + `booking_requests.required_caregiver_gender` — lands in b8.) +- **Money is IRR `BIGINT`.** `price` in results is a **digit string** (`"500000"`); `min_price`/`max_price` + filters are integers. No floats anywhere. +- **Rating sort only (MVP).** Results are ordered by `averageRating` desc, tiebroken by `totalReviews` desc + then `nurseId`/`variantId` so paging is deterministic. +- **Availability is not a filter.** Availability slots are soft guidance; they never hard-filter search (b7). + +## Enums used +- `nurse_gender`: `male` | `female`. +- `price_unit`: `per_hour` | `per_session` | `per_half_day` | `per_day` | `per_24h` (copied from the variant). + +## Endpoints + +### `GET api/v1/search/nurses` +- **Purpose:** the single family-facing discovery query over the maintained search index. +- **Auth:** none (public, pre-auth discovery) · **Rate-limited:** yes (per-IP global limiter) · **Idempotency key:** no +- **Query params:** + - `service_category_id` (long, **required**) — the primary search dimension. + - `city_id` (long, **required**). + - `district_id` (long, optional) — omit for a whole-city search; see geography rule above. + - `nurse_gender` (`male`|`female`, optional) — the same-gender facet. + - `min_price` / `max_price` (long IRR, optional) — inclusive range over the copied `price`. + - `price_unit` (enum, optional) — compare like-for-like listings (e.g. only `per_day`). + - `page` (int, default 1), `page_size` (int, default 50, max 100). +- **Success `200` payload (`data` = `PagedResultOfNurseSearchResultDto`):** + ```json + { + "items": [ + { + "variantId": 12, + "nurseId": 5, + "serviceCategoryId": 1, + "price": "8000000", + "priceUnit": "per_24h", + "nurseGender": "female", + "averageRating": 4.8, + "totalReviews": 9, + "totalCompletedBookings": 12, + "cityId": 101, + "districtId": 1003 + } + ], + "total": 1, + "page": 1, + "pageSize": 50 + } + ``` +- **Failure cases:** `400` — `service_category_id`/`city_id` missing or ≤ 0, `nurse_gender` not `male`/`female`, + `min_price > max_price`, invalid `price_unit`, or `page_size > 100`. +- **Notes:** returns only `is_searchable = 1` rows. `districtId = null` in a result row means the nurse covers + the whole city. No entity is hydrated — the read is a projected, paginated, `AsNoTracking` index scan. + +### `POST api/v1/admin_search/rebuild_index` +- **Purpose:** idempotent full rebuild of the search index from source — the convergence/reconciliation path + (first-launch / nightly / after a bulk data fix). +- **Auth:** admin (dynamic-permission policy) · **Rate-limited:** yes (`sensitive`) · **Idempotency key:** no +- **Request body:** none. +- **Success `200` payload (`data` = `SearchIndexRebuildResult`):** + ```json + { "nursesProcessed": 128, "rowsWritten": 342 } + ``` +- **Failure cases:** `401` unauthenticated · `403` non-admin. +- **Notes:** truncates and repopulates the whole index in nurse-batches; the rebuilt index's live/searchable + rows must match the incrementally-maintained state (no duplicate variant×area rows). Writes an audit-log row. + +## Shared shapes +- `NurseSearchResultDto`: `variantId` (long), `nurseId` (long), `serviceCategoryId` (long), + `price` (string, IRR digits), `priceUnit` (enum), `nurseGender` (`male`/`female`), `averageRating` (decimal), + `totalReviews` (int), `totalCompletedBookings` (int), `cityId` (long), `districtId` (long?, null = whole city). +- `SearchIndexRebuildResult`: `nursesProcessed` (int), `rowsWritten` (int). + +## Backend seam (not a wire shape) +- **`INurseSearch`** — the search-service seam. MVP impl `SqlNurseSearch` (real, over `nurse_search_index`). + Config key `Search:Backend` (default `sql`); a later `ElasticNurseSearch` is a config-selected drop-in. + +## Changelog +- b7 — initial contract: public `search/nurses`, admin `admin_search/rebuild_index`. diff --git a/dev/contracts/openapi/swagger.v1.json b/dev/contracts/openapi/swagger.v1.json index 28b8e0b..3816927 100644 --- a/dev/contracts/openapi/swagger.v1.json +++ b/dev/contracts/openapi/swagger.v1.json @@ -1363,6 +1363,71 @@ ] } }, + "/api/v1/admin_search/rebuild_index": { + "post": { + "tags": [ + "AdminSearch" + ], + "operationId": "AdminSearch_RebuildIndex", + "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/ApiResultOfSearchIndexRebuildResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/admin_verifications": { "get": { "tags": [ @@ -3721,7 +3786,7 @@ "tags": [ "Me" ], - "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.", + "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.", "operationId": "Me_SelectRole", "requestBody": { "x-name": "command", @@ -6639,6 +6704,160 @@ ] } }, + "/api/v1/search/nurses": { + "get": { + "tags": [ + "Search" + ], + "operationId": "Search_Nurses", + "parameters": [ + { + "name": "service_category_id", + "x-originalName": "serviceCategoryId", + "in": "query", + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + }, + { + "name": "city_id", + "x-originalName": "cityId", + "in": "query", + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 2 + }, + { + "name": "district_id", + "x-originalName": "districtId", + "in": "query", + "schema": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "x-position": 3 + }, + { + "name": "nurse_gender", + "x-originalName": "nurseGender", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 4 + }, + { + "name": "min_price", + "x-originalName": "minPrice", + "in": "query", + "schema": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "x-position": 5 + }, + { + "name": "max_price", + "x-originalName": "maxPrice", + "in": "query", + "schema": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "x-position": 6 + }, + { + "name": "price_unit", + "x-originalName": "priceUnit", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 7 + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 8 + }, + { + "name": "page_size", + "x-originalName": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 9 + } + ], + "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/ApiResultOfPagedResultOfNurseSearchResultDto" + } + } + } + } + } + } + }, "/api/v1/support_alerts/get_support_alerts": { "get": { "tags": [ @@ -7617,6 +7836,41 @@ } } }, + "ApiResultOfSearchIndexRebuildResult": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/SearchIndexRebuildResult" + } + ] + } + } + } + ] + }, + "SearchIndexRebuildResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "nursesProcessed": { + "type": "integer", + "format": "int32" + }, + "rowsWritten": { + "type": "integer", + "format": "int32" + } + } + }, "ApiResultOfPagedResultOfAdminPendingStepDto": { "allOf": [ { @@ -10288,6 +10542,98 @@ } } }, + "ApiResultOfPagedResultOfNurseSearchResultDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/PagedResultOfNurseSearchResultDto" + } + } + } + ] + }, + "PagedResultOfNurseSearchResultDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/NurseSearchResultDto" + } + }, + "total": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + } + } + }, + "NurseSearchResultDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "variantId": { + "type": "integer", + "format": "int64" + }, + "nurseId": { + "type": "integer", + "format": "int64" + }, + "serviceCategoryId": { + "type": "integer", + "format": "int64" + }, + "price": { + "type": "string", + "nullable": true + }, + "priceUnit": { + "type": "string", + "nullable": true + }, + "nurseGender": { + "type": "string", + "nullable": true + }, + "averageRating": { + "type": "number", + "format": "decimal" + }, + "totalReviews": { + "type": "integer", + "format": "int32" + }, + "totalCompletedBookings": { + "type": "integer", + "format": "int32" + }, + "cityId": { + "type": "integer", + "format": "int64" + }, + "districtId": { + "type": "integer", + "format": "int64", + "nullable": true + } + } + }, "ApiResultOfPagedResultOfSupportAlertDto": { "allOf": [ { diff --git a/dev/shared-working-context/backend/STATUS.md b/dev/shared-working-context/backend/STATUS.md index cf4b5b0..231f68e 100644 --- a/dev/shared-working-context/backend/STATUS.md +++ b/dev/shared-working-context/backend/STATUS.md @@ -12,6 +12,31 @@ One block per completed backend phase. Newest at the top. Backend lane writes he - **Notes for frontend:** --> +## 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 × + covered area)** with copied category/price/unit, `city_id`/`district_id` (NULL = whole city), `nurse_gender` + + rating aggregates, and the single **`is_searchable`** gate. It is a **read-only projection**, maintained + **inline in each source write's own transaction** by **`ISearchIndexMaintainer`** (`SearchIndexMaintainer`) + wired into the b3/b4/b5/b6 handlers (`ReindexVariant`/`ReindexNurse`/`FanOutServiceArea`/ + `RemoveServiceAreaRows` + `Rebuild`). Read side is the **`INurseSearch`** seam — real MVP impl + `SqlNurseSearch` (reads only `is_searchable=1`; category/city/district(NULL-aware)/gender/price filters + + rating sort + pagination). **2 controllers:** public `SearchController` (`GET search/nurses`) + admin + `AdminSearchController` (`POST admin_search/rebuild_index`, idempotent convergence rebuild). Covering search + index + filtered-unique `(variant_id, city_id, district_id)` pair (NULL participating) + `nurse_id` index. +- **Contracts:** dev/contracts/domains/search.md + openapi snapshot refreshed (yes — `search/nurses` + + `admin_search/rebuild_index` + DTOs). +- **Mocked:** `INurseSearch` → 🟢 **SQL is real** (Elastic backend 🟡 deferred, config `Search:Backend`); + `ISearchIndexMaintainer` inline path real, outbox/feeder 🟡 deferred (see reports/mocks-registry.md). +- **Gate:** build clean (0 new code warnings) / tests green (167 pass: +9 DB-backed search + 4 API integration; + affected b3/b4/b5/b6 handler tests updated for the new dependency). +- **Handoff:** backend/handoff/after-backend-phase-7.md +- **Notes for frontend:** f6-b7 = `GET api/v1/search/nurses` (public; snake_case params + `service_category_id`/`city_id` required, optional `district_id`/`nurse_gender`/`min_price`/`max_price`/ + `price_unit`; `page`/`page_size` default 1/50 max 100). Returns **only searchable** nurses; + `districtId=null` result = whole city; `price` is an IRR **digit string**; sort is rating-desc only. + `required_caregiver_gender` capture into booking is **b8**. + ## backend-phase-6 — Nurse verification & credentials (mocked vendors) — 2026-07-02 - **Shipped:** the trust engine via one additive migration — new **`verif`** schema, **5 tables**: `NurseVerifications` (`status` = the **single source of verification truth**), `VerificationStepTypes` diff --git a/dev/shared-working-context/backend/handoff/after-backend-phase-7.md b/dev/shared-working-context/backend/handoff/after-backend-phase-7.md new file mode 100644 index 0000000..24fa601 --- /dev/null +++ b/dev/shared-working-context/backend/handoff/after-backend-phase-7.md @@ -0,0 +1,50 @@ +# Handoff — after backend-phase-7 (Search & matching) + +**Search is live.** Verified nurses are now discoverable through one public endpoint backed by a +denormalized, maintained-on-write index. This unblocks **frontend f6-b7**: search + filters (C1), results +list (C2), and the nurse profile (C3) can be built against a real API. + +## What the frontend can now build (f6-b7) + +- **Search + filters (C1)** → `GET api/v1/search/nurses` (public, no auth). Query params (snake_case): + `service_category_id` (**required**), `city_id` (**required**), `district_id` (optional), + `nurse_gender` (`male`/`female`, optional), `min_price`/`max_price` (IRR long, optional), + `price_unit` (optional), `page`/`page_size` (default 1 / 50, max 100). +- **Results list (C2)** → the `data` is a `PagedResult` (`items`, `total`, `page`, + `pageSize`). Each item: `variantId`, `nurseId`, `serviceCategoryId`, `price` (IRR **digit string**), + `priceUnit`, `nurseGender`, `averageRating`, `totalReviews`, `totalCompletedBookings`, `cityId`, + `districtId` (null = whole city). +- **Nurse profile (C3)** → reuse the b6 public trust badge (`GET api/v1/nurses/{id}/trust_badge`) and the b5 + public variant read (`GET api/v1/nurse_variants/get/{id}`) already live; b7 adds no new profile route. + +Categories/cities/districts for the filter dropdowns come from the **b4** geo lookups (`geo/*`) and **b5** +catalog (`catalog/*`) — unchanged. + +## Rules the UI must respect + +- **Only searchable nurses come back.** The backend returns a nurse **only** when verified + not suspended + + accepting + variant active. No client-side re-check needed; an empty page is a valid result. +- **`districtId = null` = whole city.** A city-only search returns both whole-city and district rows; a + district search returns that district's rows **plus** whole-city rows. Show whole-city hits as covering the + district the user searched. +- **Same-gender filter is first-class.** Surface `nurse_gender` prominently; never default it silently. + (Carrying the chosen gender into the booking request — `required_caregiver_gender` — is **b8**, not here.) +- **`price` is an IRR digit string** — render with a formatter; never parse to a float. Combine with + `priceUnit` (+ `sessionCount` from the variant, when booking) for the engagement total. +- **Sort is rating-desc only** (MVP). No client sort options beyond what the API returns. + +## Contracts + +- New: [`dev/contracts/domains/search.md`](../../contracts/domains/search.md). +- `swagger.v1.json` refreshed (adds `search/nurses` + `admin_search/rebuild_index` + the DTOs). + +## Backend notes (not frontend-facing) + +- The index is a **read-only projection** maintained inline inside each source write's transaction + (`ISearchIndexMaintainer`, wired into the b3/b4/b5/b6 handlers). The read seam is **`INurseSearch`** + (SQL now; Elasticsearch is a config-selected drop-in later, `Search:Backend`). +- Admin `POST api/v1/admin_search/rebuild_index` (dynamic-permission) does an idempotent full rebuild — the + reconciliation path; incremental maintenance and rebuild converge. +- **Deferred to b8:** `booking_requests.required_caregiver_gender` capture (carry the chosen gender into the + booking). **Deferred:** Elasticsearch backend + feeder, availability hard-filter, map/radius discovery, + ranking beyond rating. diff --git a/dev/shared-working-context/reports/backend-phase-7-report.md b/dev/shared-working-context/reports/backend-phase-7-report.md new file mode 100644 index 0000000..619f231 --- /dev/null +++ b/dev/shared-working-context/reports/backend-phase-7-report.md @@ -0,0 +1,83 @@ +# Backend Phase 7 report — Search & matching (nurse search index) + +## What was built + +- **`nurse_search_index` read model** — `Domain/Entities/Search/NurseSearchIndex` (table `search.NurseSearchIndices`), + one flat row per **(bookable variant × covered service area)**: copied `variant_id`/`nurse_id`/ + `service_category_id`/`price`/`price_unit`, the covered `city_id`/`district_id` (NULL = whole city), the + nurse's `nurse_gender` + `average_rating`/`total_reviews`/`total_completed_bookings`, the single + `is_searchable` gate, `updated_at`, soft-delete `deleted_at`. EF config in + `Persistence/Configuration/SearchConfig/`; one migration `NurseSearchIndex`. Indexes: a **covering** search + index `(is_searchable, service_category_id, city_id, district_id) INCLUDE (price, nurse_gender, + average_rating, total_reviews, nurse_id, variant_id)`; the **filtered-unique pair** on `(variant_id, city_id, + district_id) WHERE deleted_at IS NULL` (NULL-district participating, via the `nurse_service_areas` trick); a + `nurse_id` secondary index; soft-delete query filter. +- **`ISearchIndexMaintainer` (write seam) + `SearchIndexMaintainer`** — `Persistence/Services/Search/`. Keeps + the index consistent **inline, in the source write's own unit of work**. Methods: `ReindexVariantAsync` + (variant create/edit/toggle — inserts a new variant's rows in the same graph via the `Variant` navigation), + `ReindexNurseAsync` (verification flip / suspend / accepting-toggle / rating recompute), `FanOutServiceAreaAsync` + + `RemoveServiceAreaRowsAsync` (area add/remove), `RebuildAsync` (idempotent full rebuild). Resurrects a + soft-deleted (variant × area) row on re-upsert so each pair has exactly one live row. +- **`INurseSearch` (read seam) + `SqlNurseSearch`** — `Persistence/Services/Search/`. Reads **only + `is_searchable = 1`** rows, applies category/city/district(NULL-aware)/gender/price filters + rating sort + + pagination, `AsNoTracking` + `.Select` projection; `price` formatted to a digit string in memory. +- **`SearchNursesQuery`** (`Features/Search/Queries/`) + FluentValidation validator, delegating to `INurseSearch`; + **`RebuildSearchIndexCommand`** (`Features/Search/Commands/`) → `RebuildAsync` + audit log. +- **Controllers:** public `SearchController` (`GET api/v1/search/nurses`, snake_case query params, per-IP + global rate limit) and `AdminSearchController` (`POST api/v1/admin_search/rebuild_index`, dynamic-permission + + `sensitive` rate limit). +- **Wiring into source handlers** (same-transaction maintenance): b5 `CreateVariant`/`UpdateVariant`/ + `SetVariantActive`; b4 `AddNurseServiceArea`/`RemoveNurseServiceArea`; b3 `SetNurseAcceptingBookings`; b6 + `AdminReviewStep`/`AdminSuspendVerification`/`ScanExpiringCredentials`/`RunIdentityKyc`/`RunShahkarMatch`/ + `RunBankAccountVerification`. +- **DI:** `AddPersistenceServices` registers `ISearchIndexMaintainer` + (config-selected) `INurseSearch` + (`Search:Backend`, default `sql`). + +## What is now testable and exactly how (per phase §7) + +Seed fixtures via `SearchIndexTestHost` (Foundation) or drive the live API. Verified against tests: +1. **Predicate** — a verified+accepting+not-suspended+active nurse is searchable; each missing condition + (unverified / not accepting / suspended / inactive variant) makes it not searchable, but the row is kept. +2. **Geography** — district-3 search returns the district-3 nurse **and** the whole-city (NULL) nurse; a + different district returns only the whole-city nurse; a city-only search returns both. +3. **Same-gender** — `nurse_gender=female`/`male` narrows to that gender. +4. **Price range** — `min_price`/`max_price` filter on the copied IRR `price`; result `price` is a digit string. +5. **Rating sort** — higher `average_rating` sorts first; deterministic paging. +6. **Verification flip** — suspend/un-verify → the nurse disappears from search in the same transaction; + reinstating brings them back (row resurrected, not duplicated). +7. **Service-area fan-out/remove** — adding an area adds its rows; removing it drops exactly those rows. +8. **Variant deactivate** — the variant stops appearing (`is_searchable=0`) without deleting its rows. +9. **Rebuild convergence** — `RebuildAsync` reproduces the incrementally-maintained live/searchable row set, + no duplicate (variant × area) rows. + +**Tests:** `Baya.Test.Foundation/Search/SearchIndexTests` (9 DB-backed over real EF/SQLite) + +`Baya.Test.Api/SearchApiTests` (4 WebApplicationFactory: public paged happy path, 400 missing category, 400 +invalid gender, 401 rebuild-unauth). Affected b3/b4/b5/b6 handler unit tests updated for the new dependency. +**Gate:** `dotnet build Baya.sln` 0 new warnings; `dotnet test Baya.sln` green (167 pass). + +Manual: `GET /api/v1/search/nurses?service_category_id=…&city_id=…` (public) returns the paged envelope; +`POST /api/v1/admin_search/rebuild_index` (admin) returns `{ nursesProcessed, rowsWritten }`. + +## Contracts produced / consumed + +- **Produced:** `dev/contracts/domains/search.md`; `dev/contracts/openapi/swagger.v1.json` refreshed. +- **Consumed:** b3 (profiles/gender/aggregates), b4 (service areas / geo), b5 (variants), b6 (verification status). + +## What is mocked / deferred + how to make it real + +- **Elasticsearch backend (`ElasticNurseSearch`) + outbox feeder** — DEFERRED. The SQL index is the real MVP + backend and stays the projection/fallback. Seam ready (`INurseSearch`, config `Search:Backend`; + `ISearchIndexMaintainer` change-event shape). Steps in `reports/mocks-registry.md` (both rows). +- **`booking_requests.required_caregiver_gender` capture** — owned by **b8** (carry the chosen gender into the + booking). b7 makes `nurse_gender` a first-class search facet and stops there. +- **Availability hard-filter, map/radius discovery, ranking beyond rating, preferred-nurse continuity** — + DEFERRED per the product doc. + +## Follow-ups for later phases + +- **b8** — consume `search/nurses` results into the booking flow; capture `required_caregiver_gender`. +- **Optional** — a short-TTL `ICacheService` decorator over hot (category, city, gender) result pages, + invalidated on index writes for the affected city/category (shipped no-cache at MVP). +- **Perf** — `RebuildAsync` does per-nurse reads (N+1); fine for the batched admin/nightly job, worth a + set-based rewrite if the nurse count grows large. +- **Elastic** — build the outbox + feeder when search scale demands it (both registry rows). diff --git a/dev/shared-working-context/reports/mocks-registry.md b/dev/shared-working-context/reports/mocks-registry.md index 3c41b11..bfc6167 100644 --- a/dev/shared-working-context/reports/mocks-registry.md +++ b/dev/shared-working-context/reports/mocks-registry.md @@ -33,6 +33,8 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢 | `IFieldEncryptor` | backend-phase-0 | PII encryption — AES-256-CBC + HMAC hash from a local symmetric key (`SymmetricFieldEncryptor`, `Baya.Infrastructure.CrossCutting/Seams/`) | `Seams:FieldEncryption:Key`, `Seams:FieldEncryption:HashKey` | KMS / column encryption / Key Vault / HSM | 🟡 | | `INotificationDispatcher` | backend-phase-0/**1** | Notification channels — **in-app write is now real** (`InAppNotificationDispatcher`, `Persistence/Services/Notifications/`, writes an `ops.Notifications` row); b0 log stub removed. SMS/push channels still deferred (no-op) behind the same seam | _none_ | Add SMS (`ISmsSender`) / push (FCM) channels; polling → Redis pub/sub or SignalR later | 🟡 | | `ILicenseVerificationService` | backend-phase-15 | eNamad / MoH establishment-permit — manual approve | _tbd_ | Real registry/API | 🔴 | +| `INurseSearch` | backend-phase-7 | The search-service seam (read side). **The MVP impl `SqlNurseSearch` (`Persistence/Services/Search/`) is REAL, not a mock** — it reads the maintained `nurse_search_index WHERE is_searchable=1`, applies the category/city/district (NULL=whole-city)/gender/price filters + rating sort + pagination, projected & `AsNoTracking`. Registered by `AddPersistenceServices`, config-selected. Only the DEFERRED Elasticsearch backend is unbuilt | `Search:Backend` (default `sql`; any other value throws until Elastic ships) | 1) add an Elasticsearch client package (`Elastic.Clients.Elasticsearch`) to `Directory.Packages.props`; 2) define the index mapping (the `NurseSearchResultDto` fields + `is_searchable`); 3) implement `ElasticNurseSearch : INurseSearch` (same filters/sort/paging) reading the ES index; 4) build the feeder that consumes the `ISearchIndexMaintainer` change events via an **outbox/CDC** stream into ES (see the next row); 5) point `Search:Backend=elastic` in config — **callers unchanged**; 6) keep the SQL index as the projection/fallback + the reconciliation source (`RebuildAsync`); 7) test filter/sort/paging parity vs `SqlNurseSearch` | 🟢 SQL real; Elastic 🟡 | +| `ISearchIndexMaintainer` (the "`ISearchIndexWriter`" event shape) | backend-phase-7 | The index-maintenance seam (write side). **The inline SQL path is REAL** — `SearchIndexMaintainer` (`Persistence/Services/Search/`) re-derives `nurse_search_index` from source and **stages** it inside the owning source write's unit of work (single `CommitAsync`), invoked from the b3/b4/b5/b6 handlers (`ReindexVariantAsync`/`ReindexNurseAsync`/`FanOutServiceAreaAsync`/`RemoveServiceAreaRowsAsync`/`RebuildAsync`). Only the **outbox/queue routing** for an async Elastic feeder is deferred — the seam is shaped so the same change events can later be emitted to an outbox instead of an inline upsert | _none_ | 1) introduce an `outbox` table + a SaveChanges interceptor that captures each maintainer change as an event row in the same transaction; 2) a background feeder (Hangfire/Quartz or a hosted service) reads the outbox and applies to `ElasticNurseSearch`; 3) keep the inline SQL upsert as the projection/fallback so `RebuildAsync` stays the reconciliation path; 4) test that an outbox replay converges to the same rows as the inline path | 🟡 outbox deferred (inline real) | > Exact config keys and file paths get filled in by the phase that builds each seam. Keep the > "Make it real →" column actionable enough that a developer can pick up any single row and ship it. @@ -52,3 +54,4 @@ the frontend can build before the backend phase merges, and swap to the real HTT | `AddressesApi` | `client/src/services/addresses/apis/mockApi.ts` | Customer address CRUD (list primary-first / create / update / set-primary / soft-delete) with the **exactly-one-primary** invariant enforced in-memory (first address auto-primary; promoting clears the prior; deleting the primary promotes the next). Persists the client-augmented `provinceId` (REQ-009) and the picked `latitude`/`longitude` (REQ-008) the wire DTO/create-body lack | `USE_ADDRESSES_MOCK` (`services/addresses/constants.ts`, default `true`) | b4 `customer_addresses/*` are live; deliver REQ-008 (accept the pin) + REQ-009 (`provinceId` on the DTO), then set flag `false` — `addressesClientApi` is wired (sends the pin + `pageSize`, echoes `provinceId` locally) | 🟡 | | `ServiceAreasApi` | `client/src/services/serviceAreas/apis/mockApi.ts` | Nurse coverage areas (list whole-city-first / add / remove). Enforces `UNIQUE(cityId, districtId)` exactly as the server — a duplicate (incl. a second whole-city row) throws the same **`409`** (`area_duplicate`) so the coverage editor's inline dup handling is demonstrable | `USE_SERVICE_AREAS_MOCK` (`services/serviceAreas/constants.ts`, default `true`) | b4 `nurse_service_areas/*` are live; set flag `false` — `serviceAreasClientApi` is wired (maps the server 409 to the same inline message). No hook/component change | 🟡 | | `AddressMapPicker` (map stand-in) | `client/src/components/geography/AddressMapPicker.tsx` | **Not a real map** — a bounded, tappable/draggable marker canvas (CSS grid, no Neshan/Google tiles, no network) that maps the pointer position to `{ latitude, longitude }` around the chosen city's centroid (`CITY_CENTROIDS`/`IRAN_CENTROID` in `services/geography/constants.ts`). Emits real coordinates for the create/update request | _none (component boundary)_ | Replace the canvas internals with a real map widget (Neshan/Google, inlined per the client CSP) that emits the same `{ latitude, longitude }` via `onChange` — `AddressForm` and every caller stay unchanged | 🟡 | +| `CatalogApi` | `client/src/services/catalog/apis/mockApi.ts` (+ `apis/seed.ts`) | The catalog skeleton + nurse pricing layer. **Categories mirror the b5 seed exactly** (5 categories, ids 1–5, `sortOrder` 0–4). Seeds representative **option groups/values** the fresh backend does **not** (an admin authors them per category) — incl. required + optional groups and one **cross-category** (`serviceCategoryId=null`) group — so the builder's required-option gate + cross-category rendering demo. Enforces the server's create validation in-memory: `400` missing required dimension / bad price, and the `(nurse, category, option-set)` duplicate **`409`** (via `optionSetSignature`). Variant store seeded **empty** so the offerings empty-state demos; the nurse builds variants live (across price units). `create`/`update`/`set_active`/`list`(active-first, paginated)/`get`. Money stays an **IRR digit-string** end-to-end | `USE_CATALOG_MOCK` (`services/catalog/constants.ts`, default `true`) | b5 `catalog/*` + `nurse_variants/*` are live; set flag `false` — `catalogClientApi` is wired to the action-style routes (camelCase bodies, `pageSize` pagination per REQ-010, `category_id` snake_case filter). **When swapped, categories will have NO option groups until an admin authors them** (the mock's groups were illustrative). No hook/component change | 🟡 | diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 0a31730..3fe0bff 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), + 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); + 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/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), + 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) ├── Infrastructure/ -│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service) +│ ├── 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.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)), 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), 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 @@ -195,6 +195,37 @@ seed in `Persistence/Configuration/CatalogConfig/`; per-domain repos (`ICatalogR canonical `variant_snapshot_json` and is **consumed by b8** (which owns the `booking_requests` column); this phase ships and unit-tests it but persists nothing. `nurse_search_index` is **b7's** (not built here). +**Search & matching (backend-phase-7).** A new **`search` schema** holds the single denormalized read model +`NurseSearchIndex` (table `NurseSearchIndices`) — **one flat row per (bookable variant × covered service +area)** (fan-out), copying the variant's category/price/unit, the covered `city_id`/`district_id` +(`district_id = NULL` = whole city), the nurse's `nurse_gender` + rating aggregates, and the single +`is_searchable` visibility gate. It is a **read-only projection**, written only by the maintainer that +re-derives it from source. Features under `Baya.Application/Features/Search/{Queries|Commands}/`; config in +`Persistence/Configuration/SearchConfig/`; the maintainer + SQL search in `Persistence/Services/Search/`. +Two seams live in `Application/Contracts/Search/`, registered by `AddPersistenceServices` (config key +`Search:Backend`, default `sql`): +- **`INurseSearch`** (read) — impl `SqlNurseSearch` reads **only `is_searchable = 1`** rows, applies the + category/city/district/gender/price filters + rating sort + pagination. The real MVP backend; a later + `ElasticNurseSearch` is a config-selected drop-in and callers depend only on the interface. +- **`ISearchIndexMaintainer`** (write, the "ISearchIndexWriter" shape) — `SearchIndexMaintainer` keeps the + index consistent **inline, inside the source write's own unit of work** (single `CommitAsync`), invoked + from the b3/b4/b5/b6 handlers that own each source row: `ReindexVariantAsync` (variant create/edit/toggle), + `ReindexNurseAsync` (verification flip / suspend / accepting-toggle / rating recompute), + `FanOutServiceAreaAsync` + `RemoveServiceAreaRowsAsync` (area add/remove), and `RebuildAsync` (idempotent + full rebuild — the admin `POST admin_search/rebuild_index` job). It shares the request-scoped + `ApplicationDbContext`, so it only *stages* changes; the handler's commit flushes source + projection + atomically. It reads the facts a trigger does **not** change from the DB and takes the facts it **does** + change as tracked arguments, so it never reads a stale pre-commit value. Load-bearing rules: + - **`is_searchable = 1` only when** nurse `is_verified = 1` AND `nurse_verifications.status != 'suspended'` + AND `is_accepting_bookings = 1` AND variant `is_active = 1` — recomputed on every relevant source write. + An unverified/paused/suspended/deactivated nurse or variant must **never** surface. + - **`district_id = NULL` = whole city**, both directions: a city search matches every row in the city; a + district search matches that district's rows **plus** the NULL-district (whole-city) rows. Uniqueness + (`UNIQUE(variant_id, city_id, district_id) WHERE deleted_at IS NULL`) uses the filtered-index pair (the + `nurse_service_areas` trick) so NULL participates on SQL Server; the maintainer resurrects a soft-deleted + 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. + **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/src/API/Baya.Web.Api/Controllers/V1/AdminSearchController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/AdminSearchController.cs new file mode 100644 index 0000000..b159935 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/AdminSearchController.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Search.Commands.RebuildSearchIndex; +using Baya.Application.Models.Search; +using Baya.Infrastructure.Identity.Identity.PermissionManager; +using Baya.WebFramework.Attributes; +using Baya.WebFramework.BaseController; +using Baya.WebFramework.ServiceConfiguration; +using Mediator; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace Baya.Web.Api.Controllers.V1; + +/// +/// Admin maintenance for the search index. The rebuild is the idempotent convergence/reconciliation path — +/// its result must match the incrementally-maintained index. +/// +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[Authorize(ConstantPolicies.DynamicPermission)] +[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)] +[Display(Description = "Admin search-index maintenance (full rebuild / reconciliation)")] +public sealed class AdminSearchController(ISender sender) : BaseController +{ + [HttpPost("[action]")] + [ProducesOkApiResponseType] + public async Task RebuildIndex(CancellationToken cancellationToken) + => OperationResult(await sender.Send(new RebuildSearchIndexCommand(), cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/SearchController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/SearchController.cs new file mode 100644 index 0000000..ee58cdc --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/SearchController.cs @@ -0,0 +1,43 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Search.Queries.SearchNurses; +using Baya.Application.Models.Common; +using Baya.Application.Models.Search; +using Baya.WebFramework.Attributes; +using Baya.WebFramework.BaseController; +using Mediator; +using Microsoft.AspNetCore.Mvc; + +namespace Baya.Web.Api.Controllers.V1; + +/// +/// Public nurse discovery. Pre-auth (families browse before signing in) and covered by the per-IP global +/// rate limiter. Reads only searchable (verified + accepting + active) rows via the INurseSearch seam. +/// +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[Display(Description = "Public nurse search: category + city/district geo, same-gender filter, price range, rating sort")] +public sealed class SearchController(ISender sender) : BaseController +{ + [HttpGet("[action]")] + [ProducesOkApiResponseType>] + public async Task Nurses( + [FromQuery(Name = "service_category_id")] long serviceCategoryId, + [FromQuery(Name = "city_id")] long cityId, + [FromQuery(Name = "district_id")] long? districtId, + [FromQuery(Name = "nurse_gender")] string? nurseGender, + [FromQuery(Name = "min_price")] long? minPrice, + [FromQuery(Name = "max_price")] long? maxPrice, + [FromQuery(Name = "price_unit")] string? priceUnit, + [FromQuery(Name = "page")] int page, + [FromQuery(Name = "page_size")] int pageSize, + CancellationToken cancellationToken) + => OperationResult(await sender.Send( + new SearchNursesQuery( + serviceCategoryId, cityId, districtId, nurseGender, minPrice, maxPrice, priceUnit, + page <= 0 ? 1 : page, + pageSize <= 0 ? Application.Common.Pagination.DefaultPageSize : pageSize), + cancellationToken)); +} diff --git a/server/src/Core/Baya.Application/Contracts/Search/INurseSearch.cs b/server/src/Core/Baya.Application/Contracts/Search/INurseSearch.cs new file mode 100644 index 0000000..eaaa9e8 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Search/INurseSearch.cs @@ -0,0 +1,20 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.Search; + +namespace Baya.Application.Contracts.Search; + +/// +/// The search-service seam. Discovery callers depend only on this interface — never on raw SQL or an +/// Elasticsearch client — so the MVP→Elastic swap is a registration/config change with no caller edits. +/// +/// The MVP implementation (SqlNurseSearch) is the real, production backend, not a mock: it +/// reads the maintained nurse_search_index where is_searchable = 1, applies the +/// category/city/district/gender/price filters and the rating sort, and paginates. A later +/// ElasticNurseSearch is a config-selected drop-in; the SQL index stays the projection/fallback. +/// +/// +public interface INurseSearch +{ + Task> SearchAsync(NurseSearchCriteria criteria, CancellationToken cancellationToken); +} diff --git a/server/src/Core/Baya.Application/Contracts/Search/ISearchIndexMaintainer.cs b/server/src/Core/Baya.Application/Contracts/Search/ISearchIndexMaintainer.cs new file mode 100644 index 0000000..0137098 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Search/ISearchIndexMaintainer.cs @@ -0,0 +1,55 @@ +#nullable enable +using Baya.Application.Models.Search; +using Baya.Domain.Entities.Catalog; +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.Verification; + +namespace Baya.Application.Contracts.Search; + +/// +/// The index-maintenance seam (the "ISearchIndexWriter" shape). It keeps nurse_search_index +/// consistent with its source tables. Each method is invoked by the handler that owns the source write and +/// stages its index changes on the same unit of work — the handler's single CommitAsync then +/// persists the source change and its projection atomically. A source write that rolls back rolls back its +/// index change too; the projection can never diverge on a successful commit. +/// +/// The projection is written only by the code path that owns the source row: a variant write +/// reindexes that variant, a profile/verification write reindexes that nurse, a service-area write fans +/// out / removes that nurse's rows for the area. The inline SQL path applies these today; the same change +/// events can later be routed to an outbox/queue for an Elasticsearch feeder without touching callers. +/// +/// +/// The maintainer intentionally reads the facts a given trigger does not change from the database and +/// takes the facts it does change as tracked arguments — so it never reads a stale, pre-commit value. +/// +/// +public interface ISearchIndexMaintainer +{ + /// Variant create / edit / activate / deactivate. Reprojects this variant across all the + /// nurse's active service areas (upsert one row per area) and reconciles away rows for areas no + /// longer covered. A deactivated variant keeps its rows with is_searchable = 0. Pass the tracked + /// variant entity — a freshly-created one (id still 0) is inserted in the same graph. + Task ReindexVariantAsync(NurseServiceVariant variant, CancellationToken cancellationToken); + + /// A change to the nurse's bookability or copied aggregates: the is_verified flip, + /// suspend / un-suspend, the is_accepting_bookings toggle, or a rating recompute. Re-derives + /// every row for the nurse (each variant × each active area), recomputing is_searchable and + /// refreshing the copied gender/rating fields. Pass the tracked profile (its just-changed flags/aggregates + /// are read from it); pass when this same unit of work also changed + /// verification state, else the committed status is read. + Task ReindexNurseAsync(NurseProfile profile, VerificationStatus? verificationStatus, CancellationToken cancellationToken); + + /// Service-area add. Inserts one row per non-deleted variant for the newly-covered area + /// (the area itself may not be committed yet — the city/district are taken from the write, not read + /// back), with is_searchable per the visibility predicate. + Task FanOutServiceAreaAsync(long nurseId, long cityId, long? districtId, CancellationToken cancellationToken); + + /// Service-area remove. Soft-deletes exactly the nurse's rows for that area across all + /// variants — never collapses other areas. + Task RemoveServiceAreaRowsAsync(long nurseId, long cityId, long? districtId, CancellationToken cancellationToken); + + /// Idempotent full rebuild from source (nurse_profiles × variants × active areas) — the + /// convergence/reconciliation path. Owns its own batched commits; the incrementally-maintained index and + /// a fresh rebuild must produce the same live rows. + Task RebuildAsync(CancellationToken cancellationToken); +} diff --git a/server/src/Core/Baya.Application/Features/Identity/Commands/SetNurseAcceptingBookings/SetNurseAcceptingBookingsCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Identity/Commands/SetNurseAcceptingBookings/SetNurseAcceptingBookingsCommand.Handler.cs index 55d6e8f..ed380f7 100644 --- a/server/src/Core/Baya.Application/Features/Identity/Commands/SetNurseAcceptingBookings/SetNurseAcceptingBookingsCommand.Handler.cs +++ b/server/src/Core/Baya.Application/Features/Identity/Commands/SetNurseAcceptingBookings/SetNurseAcceptingBookingsCommand.Handler.cs @@ -1,13 +1,14 @@ #nullable enable using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.Search; using Baya.Application.Models.Common; using Baya.Domain.Entities.User; using Mediator; namespace Baya.Application.Features.Identity.Commands.SetNurseAcceptingBookings; -internal sealed class SetNurseAcceptingBookingsCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork) +internal sealed class SetNurseAcceptingBookingsCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork, ISearchIndexMaintainer searchIndex) : IRequestHandler> { public async ValueTask> Handle(SetNurseAcceptingBookingsCommand request, CancellationToken cancellationToken) @@ -23,6 +24,10 @@ internal sealed class SetNurseAcceptingBookingsCommandHandler(ICurrentUser curre return OperationResult.NotFoundResult("No nurse profile exists yet. Create your profile first."); profile.SetAcceptingBookings(request.Accepting); + + // Pausing/resuming bookings flips every one of the nurse's index rows' is_searchable in the same + // transaction (verification status is unchanged here, so it is read from the committed record). + await searchIndex.ReindexNurseAsync(profile, null, cancellationToken); await unitOfWork.CommitAsync(); return OperationResult.SuccessResult(true); diff --git a/server/src/Core/Baya.Application/Features/Search/Commands/RebuildSearchIndex/RebuildSearchIndexCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Search/Commands/RebuildSearchIndex/RebuildSearchIndexCommand.Handler.cs new file mode 100644 index 0000000..ba1331e --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Search/Commands/RebuildSearchIndex/RebuildSearchIndexCommand.Handler.cs @@ -0,0 +1,38 @@ +#nullable enable +using Baya.Application.Contracts.Audit; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Search; +using Baya.Application.Models.Common; +using Baya.Application.Models.Search; +using Mediator; + +namespace Baya.Application.Features.Search.Commands.RebuildSearchIndex; + +internal sealed class RebuildSearchIndexCommandHandler( + ICurrentUser currentUser, + ISearchIndexMaintainer maintainer, + IAuditLogger auditLogger) + : IRequestHandler> +{ + public async ValueTask> Handle(RebuildSearchIndexCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } adminId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var result = await maintainer.RebuildAsync(cancellationToken); + + await auditLogger.WriteAsync( + "nurse_search_index", + "rebuild", + "rebuild", + new Dictionary + { + ["admin_id"] = adminId, + ["nurses_processed"] = result.NursesProcessed, + ["rows_written"] = result.RowsWritten + }, + cancellationToken); + + return OperationResult.SuccessResult(result); + } +} diff --git a/server/src/Core/Baya.Application/Features/Search/Commands/RebuildSearchIndex/RebuildSearchIndexCommand.cs b/server/src/Core/Baya.Application/Features/Search/Commands/RebuildSearchIndex/RebuildSearchIndexCommand.cs new file mode 100644 index 0000000..97a2cfa --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Search/Commands/RebuildSearchIndex/RebuildSearchIndexCommand.cs @@ -0,0 +1,9 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Search; +using Mediator; + +namespace Baya.Application.Features.Search.Commands.RebuildSearchIndex; + +/// Admin/nightly full rebuild of nurse_search_index from source — the convergence path. +/// Idempotent: the rebuilt index must match the incrementally-maintained one. +public record RebuildSearchIndexCommand : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Search/Queries/SearchNurses/SearchNursesQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Search/Queries/SearchNurses/SearchNursesQuery.Handler.cs new file mode 100644 index 0000000..7671652 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Search/Queries/SearchNurses/SearchNursesQuery.Handler.cs @@ -0,0 +1,31 @@ +#nullable enable +using Baya.Application.Common; +using Baya.Application.Contracts.Search; +using Baya.Application.Models.Common; +using Baya.Application.Models.Search; +using Mediator; + +namespace Baya.Application.Features.Search.Queries.SearchNurses; + +internal sealed class SearchNursesQueryHandler(INurseSearch search) + : IRequestHandler>> +{ + public async ValueTask>> Handle(SearchNursesQuery request, CancellationToken cancellationToken) + { + var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize); + + var criteria = new NurseSearchCriteria( + request.ServiceCategoryId, + request.CityId, + request.DistrictId, + string.IsNullOrWhiteSpace(request.NurseGender) ? null : request.NurseGender, + request.MinPrice, + request.MaxPrice, + string.IsNullOrWhiteSpace(request.PriceUnit) ? null : request.PriceUnit, + page, + pageSize); + + var result = await search.SearchAsync(criteria, cancellationToken); + return OperationResult>.SuccessResult(result); + } +} diff --git a/server/src/Core/Baya.Application/Features/Search/Queries/SearchNurses/SearchNursesQuery.Validator.cs b/server/src/Core/Baya.Application/Features/Search/Queries/SearchNurses/SearchNursesQuery.Validator.cs new file mode 100644 index 0000000..9f03692 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Search/Queries/SearchNurses/SearchNursesQuery.Validator.cs @@ -0,0 +1,34 @@ +using Baya.Domain.Entities.Catalog; +using FluentValidation; + +namespace Baya.Application.Features.Search.Queries.SearchNurses; + +public sealed class SearchNursesQueryValidator : AbstractValidator +{ + public SearchNursesQueryValidator() + { + RuleFor(x => x.ServiceCategoryId).GreaterThan(0); + RuleFor(x => x.CityId).GreaterThan(0); + RuleFor(x => x.DistrictId).GreaterThan(0).When(x => x.DistrictId.HasValue); + + // Same-gender matching is a first-class facet — when present it must be an exact known value. + RuleFor(x => x.NurseGender) + .Must(g => g is "male" or "female") + .When(x => !string.IsNullOrWhiteSpace(x.NurseGender)) + .WithMessage("nurse_gender must be 'male' or 'female'."); + + RuleFor(x => x.MinPrice).GreaterThanOrEqualTo(0).When(x => x.MinPrice.HasValue); + RuleFor(x => x.MaxPrice).GreaterThanOrEqualTo(0).When(x => x.MaxPrice.HasValue); + RuleFor(x => x) + .Must(x => x.MinPrice <= x.MaxPrice) + .When(x => x.MinPrice.HasValue && x.MaxPrice.HasValue) + .WithMessage("min_price must be less than or equal to max_price."); + + RuleFor(x => x.PriceUnit) + .Must(PriceUnits.IsValid) + .When(x => !string.IsNullOrWhiteSpace(x.PriceUnit)) + .WithMessage("price_unit must be one of: per_hour, per_session, per_half_day, per_day, per_24h."); + + RuleFor(x => x.PageSize).LessThanOrEqualTo(Baya.Application.Common.Pagination.MaxPageSize); + } +} diff --git a/server/src/Core/Baya.Application/Features/Search/Queries/SearchNurses/SearchNursesQuery.cs b/server/src/Core/Baya.Application/Features/Search/Queries/SearchNurses/SearchNursesQuery.cs new file mode 100644 index 0000000..22cd115 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Search/Queries/SearchNurses/SearchNursesQuery.cs @@ -0,0 +1,24 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.Search; +using Mediator; + +namespace Baya.Application.Features.Search.Queries.SearchNurses; + +/// +/// The single family-facing discovery query. Category + city are required; district is optional (NULL = +/// whole-city geography is resolved by the backend). Same-gender matching is a first-class facet; price is +/// an IRR long range. Only is_searchable = 1 rows are ever returned. Delegates to the +/// seam so an Elasticsearch backend can drop in +/// later by configuration alone. +/// +public record SearchNursesQuery( + long ServiceCategoryId, + long CityId, + long? DistrictId = null, + string? NurseGender = null, + long? MinPrice = null, + long? MaxPrice = null, + string? PriceUnit = null, + int Page = 1, + int PageSize = 50) : IRequest>>; diff --git a/server/src/Core/Baya.Application/Features/ServiceAreas/Commands/AddNurseServiceArea/AddNurseServiceAreaCommand.Handler.cs b/server/src/Core/Baya.Application/Features/ServiceAreas/Commands/AddNurseServiceArea/AddNurseServiceAreaCommand.Handler.cs index 74769b4..8ce1797 100644 --- a/server/src/Core/Baya.Application/Features/ServiceAreas/Commands/AddNurseServiceArea/AddNurseServiceAreaCommand.Handler.cs +++ b/server/src/Core/Baya.Application/Features/ServiceAreas/Commands/AddNurseServiceArea/AddNurseServiceAreaCommand.Handler.cs @@ -1,6 +1,7 @@ #nullable enable using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.Search; using Baya.Application.Models.Common; using Baya.Application.Models.Geography; using Baya.Domain.Entities.Geography; @@ -9,7 +10,7 @@ using Mediator; namespace Baya.Application.Features.ServiceAreas.Commands.AddNurseServiceArea; -internal sealed class AddNurseServiceAreaCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork) +internal sealed class AddNurseServiceAreaCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork, ISearchIndexMaintainer searchIndex) : IRequestHandler> { public async ValueTask> Handle(AddNurseServiceAreaCommand request, CancellationToken cancellationToken) @@ -53,9 +54,11 @@ internal sealed class AddNurseServiceAreaCommandHandler(ICurrentUser currentUser IsActive = true }; - // DEFERRED (b7): this is the write that later fans out nurse_search_index rows. Keep it the single - // trigger point — do not build the index here. await unitOfWork.NurseServiceAreaRepository.AddAsync(area, cancellationToken); + + // Fan the newly-covered area out into nurse_search_index: one row per active variant, in the same + // transaction. The area itself may still be uncommitted, so its city/district come from the request. + await searchIndex.FanOutServiceAreaAsync(nid, request.CityId, request.DistrictId, cancellationToken); await unitOfWork.CommitAsync(); return OperationResult.SuccessResult(new NurseServiceAreaDto( diff --git a/server/src/Core/Baya.Application/Features/ServiceAreas/Commands/RemoveNurseServiceArea/RemoveNurseServiceAreaCommand.Handler.cs b/server/src/Core/Baya.Application/Features/ServiceAreas/Commands/RemoveNurseServiceArea/RemoveNurseServiceAreaCommand.Handler.cs index 1f315a5..4d24851 100644 --- a/server/src/Core/Baya.Application/Features/ServiceAreas/Commands/RemoveNurseServiceArea/RemoveNurseServiceAreaCommand.Handler.cs +++ b/server/src/Core/Baya.Application/Features/ServiceAreas/Commands/RemoveNurseServiceArea/RemoveNurseServiceAreaCommand.Handler.cs @@ -1,6 +1,7 @@ #nullable enable using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.Search; using Baya.Application.Models.Common; using Baya.Domain.Entities.User; using Mediator; @@ -10,7 +11,8 @@ namespace Baya.Application.Features.ServiceAreas.Commands.RemoveNurseServiceArea internal sealed class RemoveNurseServiceAreaCommandHandler( ICurrentUser currentUser, IUnitOfWork unitOfWork, - IDateTimeProvider clock) + IDateTimeProvider clock, + ISearchIndexMaintainer searchIndex) : IRequestHandler> { public async ValueTask> Handle(RemoveNurseServiceAreaCommand request, CancellationToken cancellationToken) @@ -30,8 +32,11 @@ internal sealed class RemoveNurseServiceAreaCommandHandler( if (area is null) return OperationResult.NotFoundResult("Service area not found."); - // DEFERRED (b7): triggers nurse_search_index row removal — keep this the single trigger point. area.DeletedAt = clock.UtcNow; + + // Drop exactly this nurse×area's index rows across all variants, in the same transaction. Removing an + // area must never collapse or touch the nurse's other areas. + await searchIndex.RemoveServiceAreaRowsAsync(area.NurseId, area.CityId, area.DistrictId, cancellationToken); await unitOfWork.CommitAsync(); return OperationResult.SuccessResult(true); diff --git a/server/src/Core/Baya.Application/Features/Variants/Commands/CreateVariant/CreateVariantCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Variants/Commands/CreateVariant/CreateVariantCommand.Handler.cs index e8d5814..07e3580 100644 --- a/server/src/Core/Baya.Application/Features/Variants/Commands/CreateVariant/CreateVariantCommand.Handler.cs +++ b/server/src/Core/Baya.Application/Features/Variants/Commands/CreateVariant/CreateVariantCommand.Handler.cs @@ -3,6 +3,7 @@ using System.Globalization; using Baya.Application.Common; using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.Search; using Baya.Application.Models.Catalog; using Baya.Application.Models.Common; using Baya.Domain.Entities.Catalog; @@ -11,7 +12,7 @@ using Mediator; namespace Baya.Application.Features.Variants.Commands.CreateVariant; -internal sealed class CreateVariantCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork) +internal sealed class CreateVariantCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork, ISearchIndexMaintainer searchIndex) : IRequestHandler> { public async ValueTask> Handle(CreateVariantCommand request, CancellationToken cancellationToken) @@ -91,9 +92,11 @@ internal sealed class CreateVariantCommandHandler(ICurrentUser currentUser, IUni .ToList() }; - // DEFERRED (b7): this is the write that later fans a variant out into nurse_search_index. Keep it the - // single trigger point — do not build the index here. await unitOfWork.NurseServiceVariantRepository.AddAsync(variant, cancellationToken); + + // Fan this variant out into nurse_search_index across the nurse's service areas, in the same unit of + // work — the new variant's generated id is assigned to its index rows on the single commit below. + await searchIndex.ReindexVariantAsync(variant, cancellationToken); await unitOfWork.CommitAsync(); return OperationResult.SuccessResult(new VariantDto( diff --git a/server/src/Core/Baya.Application/Features/Variants/Commands/SetVariantActive/SetVariantActiveCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Variants/Commands/SetVariantActive/SetVariantActiveCommand.Handler.cs index 7f09fcf..0d07aa1 100644 --- a/server/src/Core/Baya.Application/Features/Variants/Commands/SetVariantActive/SetVariantActiveCommand.Handler.cs +++ b/server/src/Core/Baya.Application/Features/Variants/Commands/SetVariantActive/SetVariantActiveCommand.Handler.cs @@ -1,13 +1,14 @@ #nullable enable using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.Search; using Baya.Application.Models.Common; using Baya.Domain.Entities.User; using Mediator; namespace Baya.Application.Features.Variants.Commands.SetVariantActive; -internal sealed class SetVariantActiveCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork) +internal sealed class SetVariantActiveCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork, ISearchIndexMaintainer searchIndex) : IRequestHandler> { public async ValueTask> Handle(SetVariantActiveCommand request, CancellationToken cancellationToken) @@ -28,7 +29,9 @@ internal sealed class SetVariantActiveCommandHandler(ICurrentUser currentUser, I variant.IsActive = request.IsActive; - // DEFERRED (b7): toggling active is the trigger point for the search-index add/remove. + // Deactivate flips this variant's index rows to is_searchable=0 (kept, not deleted); activate makes + // them searchable again — recomputed and staged in the same transaction as the toggle. + await searchIndex.ReindexVariantAsync(variant, cancellationToken); await unitOfWork.CommitAsync(); return OperationResult.SuccessResult(true); diff --git a/server/src/Core/Baya.Application/Features/Variants/Commands/UpdateVariant/UpdateVariantCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Variants/Commands/UpdateVariant/UpdateVariantCommand.Handler.cs index a94d3fb..d223dfa 100644 --- a/server/src/Core/Baya.Application/Features/Variants/Commands/UpdateVariant/UpdateVariantCommand.Handler.cs +++ b/server/src/Core/Baya.Application/Features/Variants/Commands/UpdateVariant/UpdateVariantCommand.Handler.cs @@ -2,6 +2,7 @@ using System.Globalization; using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.Search; using Baya.Application.Models.Catalog; using Baya.Application.Models.Common; using Baya.Domain.Entities.User; @@ -9,7 +10,7 @@ using Mediator; namespace Baya.Application.Features.Variants.Commands.UpdateVariant; -internal sealed class UpdateVariantCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork) +internal sealed class UpdateVariantCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork, ISearchIndexMaintainer searchIndex) : IRequestHandler> { public async ValueTask> Handle(UpdateVariantCommand request, CancellationToken cancellationToken) @@ -35,6 +36,8 @@ internal sealed class UpdateVariantCommandHandler(ICurrentUser currentUser, IUni if (!string.IsNullOrWhiteSpace(request.DisplayName)) variant.DisplayName = request.DisplayName.Trim(); + // Price/category changes must reach the search projection in the same transaction. + await searchIndex.ReindexVariantAsync(variant, cancellationToken); await unitOfWork.CommitAsync(); // Re-project with resolved labels for the response (the option-set is unchanged). diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/ReviewStep/AdminReviewStepCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/ReviewStep/AdminReviewStepCommand.Handler.cs index ad216ec..e3c74bc 100644 --- a/server/src/Core/Baya.Application/Features/Verification/Commands/ReviewStep/AdminReviewStepCommand.Handler.cs +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/ReviewStep/AdminReviewStepCommand.Handler.cs @@ -3,6 +3,7 @@ using Baya.Application.Common; using Baya.Application.Contracts.Audit; using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.Search; using Baya.Application.Models.Common; using Baya.Application.Models.Verification; using Baya.Domain.Entities.Verification; @@ -16,7 +17,8 @@ internal sealed class AdminReviewStepCommandHandler( ICredentialVerifier credentialVerifier, IAuditLogger auditLogger, ICacheService cache, - IDateTimeProvider dateTimeProvider) + IDateTimeProvider dateTimeProvider, + ISearchIndexMaintainer searchIndex) : IRequestHandler> { public async ValueTask> Handle(AdminReviewStepCommand request, CancellationToken cancellationToken) @@ -69,6 +71,10 @@ internal sealed class AdminReviewStepCommandHandler( VerificationAggregator.Finalize(verification, profile, now); + // Any is_verified flip must reach the search projection in the same transaction — a newly-verified + // nurse's rows become searchable; a rejection reverses it. + await searchIndex.ReindexNurseAsync(profile, verification.Status, cancellationToken); + // The step decision, the recorded credential, and any is_verified flip land in one transaction. await unitOfWork.CommitAsync(); diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/RunBankAccountVerification/RunBankAccountVerificationCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/RunBankAccountVerification/RunBankAccountVerificationCommand.Handler.cs index a85dc84..6ccfe45 100644 --- a/server/src/Core/Baya.Application/Features/Verification/Commands/RunBankAccountVerification/RunBankAccountVerificationCommand.Handler.cs +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/RunBankAccountVerification/RunBankAccountVerificationCommand.Handler.cs @@ -3,6 +3,7 @@ using System.Text.Json; using Baya.Application.Common; using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.Search; using Baya.Application.Models.Common; using Baya.Application.Models.Verification; using Baya.Domain.Entities.User; @@ -15,7 +16,8 @@ internal sealed class RunBankAccountVerificationCommandHandler( ICurrentUser currentUser, IUnitOfWork unitOfWork, IBankAccountOwnershipVerifier ownershipVerifier, - IDateTimeProvider dateTimeProvider) + IDateTimeProvider dateTimeProvider, + ISearchIndexMaintainer searchIndex) : IRequestHandler> { public async ValueTask> Handle(RunBankAccountVerificationCommand request, CancellationToken cancellationToken) @@ -77,6 +79,9 @@ internal sealed class RunBankAccountVerificationCommandHandler( return OperationResult.NotFoundResult("Nurse profile not found."); VerificationAggregator.Finalize(verification, profile, now); + + // An automated pass can flip is_verified — keep the search projection in step within this commit. + await searchIndex.ReindexNurseAsync(profile, verification.Status, cancellationToken); await unitOfWork.CommitAsync(); return OperationResult.SuccessResult(new RunStepResult(step.Id, step.Status.ToCode(), step.FailureReason)); diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/RunIdentityKyc/RunIdentityKycCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/RunIdentityKyc/RunIdentityKycCommand.Handler.cs index 74996b1..292b12e 100644 --- a/server/src/Core/Baya.Application/Features/Verification/Commands/RunIdentityKyc/RunIdentityKycCommand.Handler.cs +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/RunIdentityKyc/RunIdentityKycCommand.Handler.cs @@ -2,6 +2,7 @@ using Baya.Application.Common; using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.Search; using Baya.Application.Models.Common; using Baya.Application.Models.Verification; using Baya.Domain.Entities.User; @@ -14,7 +15,8 @@ internal sealed class RunIdentityKycCommandHandler( ICurrentUser currentUser, IUnitOfWork unitOfWork, IIdentityKycProvider identityKyc, - IDateTimeProvider dateTimeProvider) + IDateTimeProvider dateTimeProvider, + ISearchIndexMaintainer searchIndex) : IRequestHandler> { public async ValueTask> Handle(RunIdentityKycCommand request, CancellationToken cancellationToken) @@ -68,6 +70,9 @@ internal sealed class RunIdentityKycCommandHandler( return OperationResult.NotFoundResult("Nurse profile not found."); VerificationAggregator.Finalize(verification, profile, now); + + // An automated pass can flip is_verified — keep the search projection in step within this commit. + await searchIndex.ReindexNurseAsync(profile, verification.Status, cancellationToken); await unitOfWork.CommitAsync(); return OperationResult.SuccessResult(new RunStepResult(step.Id, step.Status.ToCode(), step.FailureReason)); diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/RunShahkarMatch/RunShahkarMatchCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/RunShahkarMatch/RunShahkarMatchCommand.Handler.cs index 32a7366..42678b7 100644 --- a/server/src/Core/Baya.Application/Features/Verification/Commands/RunShahkarMatch/RunShahkarMatchCommand.Handler.cs +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/RunShahkarMatch/RunShahkarMatchCommand.Handler.cs @@ -2,6 +2,7 @@ using Baya.Application.Common; using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.Search; using Baya.Application.Contracts.SupportAlerts; using Baya.Application.Models.Common; using Baya.Application.Models.Verification; @@ -17,7 +18,8 @@ internal sealed class RunShahkarMatchCommandHandler( IUnitOfWork unitOfWork, IShahkarVerifier shahkarVerifier, ISupportAlertService supportAlerts, - IDateTimeProvider dateTimeProvider) + IDateTimeProvider dateTimeProvider, + ISearchIndexMaintainer searchIndex) : IRequestHandler> { public async ValueTask> Handle(RunShahkarMatchCommand request, CancellationToken cancellationToken) @@ -71,6 +73,9 @@ internal sealed class RunShahkarMatchCommandHandler( return OperationResult.NotFoundResult("Nurse profile not found."); VerificationAggregator.Finalize(verification, profile, now); + + // An automated pass can flip is_verified — keep the search projection in step within this commit. + await searchIndex.ReindexNurseAsync(profile, verification.Status, cancellationToken); await unitOfWork.CommitAsync(); // Shared-SIM is a distinct, non-accusatory handled state — flag it for staff follow-up. Raised diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/ScanExpiringCredentials/ScanExpiringCredentialsCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/ScanExpiringCredentials/ScanExpiringCredentialsCommand.Handler.cs index 3cdbbc0..4cd0658 100644 --- a/server/src/Core/Baya.Application/Features/Verification/Commands/ScanExpiringCredentials/ScanExpiringCredentialsCommand.Handler.cs +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/ScanExpiringCredentials/ScanExpiringCredentialsCommand.Handler.cs @@ -2,6 +2,7 @@ using Baya.Application.Common; using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.Search; using Baya.Application.Contracts.SupportAlerts; using Baya.Application.Models.Common; using Baya.Application.Models.Verification; @@ -16,7 +17,8 @@ internal sealed class ScanExpiringCredentialsCommandHandler( ISupportAlertService supportAlerts, INotificationDispatcher notifications, ICacheService cache, - IDateTimeProvider dateTimeProvider) + IDateTimeProvider dateTimeProvider, + ISearchIndexMaintainer searchIndex) : IRequestHandler> { public async ValueTask> Handle(ScanExpiringCredentialsCommand request, CancellationToken cancellationToken) @@ -62,6 +64,9 @@ internal sealed class ScanExpiringCredentialsCommandHandler( // A lapsed required credential must never silently keep a nurse verified — re-gate atomically. VerificationAggregator.Finalize(verification, profile, now); + + // The un-verify must reach search in the same commit so an expired nurse stops surfacing. + await searchIndex.ReindexNurseAsync(profile, verification.Status, cancellationToken); await unitOfWork.CommitAsync(); revertedNurses++; diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/SuspendVerification/AdminSuspendVerificationCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/SuspendVerification/AdminSuspendVerificationCommand.Handler.cs index 72a162e..2dba971 100644 --- a/server/src/Core/Baya.Application/Features/Verification/Commands/SuspendVerification/AdminSuspendVerificationCommand.Handler.cs +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/SuspendVerification/AdminSuspendVerificationCommand.Handler.cs @@ -3,6 +3,7 @@ using Baya.Application.Common; using Baya.Application.Contracts.Audit; using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.Search; using Baya.Application.Models.Common; using Baya.Domain.Entities.Verification; using Mediator; @@ -14,7 +15,8 @@ internal sealed class AdminSuspendVerificationCommandHandler( IUnitOfWork unitOfWork, IAuditLogger auditLogger, ICacheService cache, - IDateTimeProvider dateTimeProvider) + IDateTimeProvider dateTimeProvider, + ISearchIndexMaintainer searchIndex) : IRequestHandler> { public async ValueTask> Handle(AdminSuspendVerificationCommand request, CancellationToken cancellationToken) @@ -41,6 +43,9 @@ internal sealed class AdminSuspendVerificationCommandHandler( // Suspended status → the aggregator reverses is_verified in the same transaction. VerificationAggregator.Finalize(verification, profile, now); + // A suspended nurse must vanish from search — flip all their rows to is_searchable=0 in this commit. + await searchIndex.ReindexNurseAsync(profile, verification.Status, cancellationToken); + await unitOfWork.CommitAsync(); await auditLogger.WriteAsync( diff --git a/server/src/Core/Baya.Application/Models/Search/NurseSearchCriteria.cs b/server/src/Core/Baya.Application/Models/Search/NurseSearchCriteria.cs new file mode 100644 index 0000000..25e9dac --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Search/NurseSearchCriteria.cs @@ -0,0 +1,18 @@ +#nullable enable +namespace Baya.Application.Models.Search; + +/// +/// Normalized inputs the backend queries. +/// Category and city are required; district is optional (NULL-district = whole-city geography is resolved +/// inside the backend). Prices are IRR Rials as long — no float. +/// +public sealed record NurseSearchCriteria( + long ServiceCategoryId, + long CityId, + long? DistrictId, + string? NurseGender, + long? MinPrice, + long? MaxPrice, + string? PriceUnit, + int Page, + int PageSize); diff --git a/server/src/Core/Baya.Application/Models/Search/NurseSearchResultDto.cs b/server/src/Core/Baya.Application/Models/Search/NurseSearchResultDto.cs new file mode 100644 index 0000000..48c5959 --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Search/NurseSearchResultDto.cs @@ -0,0 +1,19 @@ +namespace Baya.Application.Models.Search; + +/// +/// One family-facing search hit — a bookable variant matched in a covered area. Price is IRR Rials +/// as a digit string (BIGINT on the wire, never a float); DistrictId == null means the nurse covers +/// the whole city. +/// +public record NurseSearchResultDto( + long VariantId, + long NurseId, + long ServiceCategoryId, + string Price, + string PriceUnit, + string NurseGender, + decimal AverageRating, + int TotalReviews, + int TotalCompletedBookings, + long CityId, + long? DistrictId); diff --git a/server/src/Core/Baya.Application/Models/Search/SearchIndexRebuildResult.cs b/server/src/Core/Baya.Application/Models/Search/SearchIndexRebuildResult.cs new file mode 100644 index 0000000..2f0ed67 --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Search/SearchIndexRebuildResult.cs @@ -0,0 +1,5 @@ +namespace Baya.Application.Models.Search; + +/// Outcome of a full nurse_search_index rebuild: how many nurse profiles were scanned and +/// how many live index rows the rebuild produced. +public record SearchIndexRebuildResult(int NursesProcessed, int RowsWritten); diff --git a/server/src/Core/Baya.Domain/Entities/Search/NurseSearchIndex.cs b/server/src/Core/Baya.Domain/Entities/Search/NurseSearchIndex.cs new file mode 100644 index 0000000..d661503 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Search/NurseSearchIndex.cs @@ -0,0 +1,65 @@ +using Baya.Domain.Common; +using Baya.Domain.Entities.Catalog; + +namespace Baya.Domain.Entities.Search; + +/// +/// The denormalized, maintained-on-write read model behind nurse discovery (b7): one flat row per +/// (bookable variant × covered service area). It flattens facts that otherwise live across four +/// domains — the variant's category/price (catalog), the covered city/district (geography), the nurse's +/// gender + rating aggregates (identity), and the verification-derived bookability — so a family search is +/// a single indexed, paginated scan instead of a 4+ table join with a rating sort. +/// +/// This is a read-only projection: it is written only by the search-index maintainer, which +/// re-derives every field from the source tables. Never let a search read mutate it, and never treat it as +/// the source of truth for anything. +/// +/// +/// is the single visibility gate — a row is returned to families only when it is +/// true, which holds only when the nurse is verified, not suspended, accepting bookings, and +/// the variant is active (see the maintainer). == null is a meaningful +/// "whole city" coverage value, not missing data. is IRR Rials as an integer — no float. +/// +/// +public class NurseSearchIndex : BaseEntity +{ + public long VariantId { get; set; } + + /// Reference navigation so a row projected for a freshly-created variant is inserted in the same + /// graph — EF assigns the generated variant_id in one SaveChanges. + public NurseServiceVariant Variant { get; set; } + + public long NurseId { get; set; } + + public long ServiceCategoryId { get; set; } + + /// IRR Rials, integer — copied from the variant. No float money path, ever. + public long Price { get; set; } + + /// Closed code set (see ) — copied from the variant. + public string PriceUnit { get; set; } + + public long CityId { get; set; } + + /// NULL = "whole city" — a deliberate coverage value, not missing data. A city search matches + /// both NULL-district rows and any district row in the city; a district search matches that district's + /// rows plus the NULL-district (whole-city) rows. + public long? DistrictId { get; set; } + + /// Copied from users.gender via the nurse, for the first-class same-gender filter. + public string NurseGender { get; set; } + + public decimal AverageRating { get; set; } + public int TotalReviews { get; set; } + public int TotalCompletedBookings { get; set; } + + /// The single visibility gate: true only when nurse is_verified=1 AND not + /// suspended AND is_accepting_bookings=1 AND variant is_active=1. Recomputed on every + /// relevant source write — never trusted as a stale value. + public bool IsSearchable { get; set; } + + /// Stamped from on every upsert. + public DateTimeOffset UpdatedAt { get; set; } + + public DateTimeOffset? DeletedAt { get; set; } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/SearchConfig/NurseSearchIndexConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/SearchConfig/NurseSearchIndexConfig.cs new file mode 100644 index 0000000..cb8a447 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/SearchConfig/NurseSearchIndexConfig.cs @@ -0,0 +1,57 @@ +using Baya.Domain.Entities.Catalog; +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.Search; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.SearchConfig; + +internal sealed class NurseSearchIndexConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("NurseSearchIndex", "search"); + + // Price is IRR Rials as BIGINT (long → bigint) — copied from the variant. No float money path. + builder.Property(x => x.PriceUnit).HasMaxLength(20).IsRequired(); + builder.Property(x => x.NurseGender).HasMaxLength(10); + builder.Property(x => x.AverageRating).HasPrecision(3, 2); + + // The hot search path: filter on (is_searchable, category, city, district) then rating-sort + page. + // INCLUDE the columns the projection reads so the filtered, sorted page is served straight from the + // index with no key lookups (SQL Server; the INCLUDE annotation is ignored by other providers). + builder.HasIndex(x => new { x.IsSearchable, x.ServiceCategoryId, x.CityId, x.DistrictId }) + .IncludeProperties(x => new { x.Price, x.NurseGender, x.AverageRating, x.TotalReviews, x.NurseId, x.VariantId }) + .HasDatabaseName("IX_NurseSearchIndex_Search"); + + // Exactly one live row per (variant × area) — the upsert target and anti-duplication backstop. + // SQL Server treats NULLs as distinct, so a plain UNIQUE(variant, city, district) would wrongly allow + // two "whole city" (NULL district) rows. Split into a filtered pair exactly like nurse_service_areas: + // one enforces at most one whole-city row, the other enforces uniqueness of city+district rows. Both + // exclude soft-deleted rows so a removed-then-recovered area re-inserts cleanly. + builder.HasIndex(x => new { x.VariantId, x.CityId }) + .IsUnique() + .HasFilter("[DistrictId] IS NULL AND [DeletedAt] IS NULL") + .HasDatabaseName("UX_NurseSearchIndex_Variant_City_WholeCity"); + + builder.HasIndex(x => new { x.VariantId, x.CityId, x.DistrictId }) + .IsUnique() + .HasFilter("[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL") + .HasDatabaseName("UX_NurseSearchIndex_Variant_City_District"); + + // A nurse-scoped rebuild / suspend / remove touches every row for one nurse — keep it cheap. + builder.HasIndex(x => x.NurseId); + + builder.HasOne(x => x.Variant) + .WithMany() + .HasForeignKey(x => x.VariantId) + .IsRequired(); + + builder.HasOne() + .WithMany() + .HasForeignKey(x => x.NurseId) + .IsRequired(); + + builder.HasQueryFilter(x => x.DeletedAt == null); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260705124508_NurseSearchIndex.Designer.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260705124508_NurseSearchIndex.Designer.cs new file mode 100644 index 0000000..7931df7 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260705124508_NurseSearchIndex.Designer.cs @@ -0,0 +1,3528 @@ +// +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("20260705124508_NurseSearchIndex")] + partial class NurseSearchIndex + { + /// + 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.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.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/20260705124508_NurseSearchIndex.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260705124508_NurseSearchIndex.cs new file mode 100644 index 0000000..6cfeca2 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260705124508_NurseSearchIndex.cs @@ -0,0 +1,100 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Baya.Infrastructure.Persistence.Migrations +{ + /// + public partial class NurseSearchIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "search"); + + migrationBuilder.CreateTable( + name: "NurseSearchIndices", + schema: "search", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + VariantId = table.Column(type: "bigint", nullable: false), + NurseId = table.Column(type: "bigint", nullable: false), + ServiceCategoryId = table.Column(type: "bigint", nullable: false), + Price = table.Column(type: "bigint", nullable: false), + PriceUnit = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + CityId = table.Column(type: "bigint", nullable: false), + DistrictId = table.Column(type: "bigint", nullable: true), + NurseGender = table.Column(type: "nvarchar(10)", maxLength: 10, nullable: true), + AverageRating = table.Column(type: "decimal(3,2)", precision: 3, scale: 2, nullable: false), + TotalReviews = table.Column(type: "int", nullable: false), + TotalCompletedBookings = table.Column(type: "int", nullable: false), + IsSearchable = table.Column(type: "bit", nullable: false), + UpdatedAt = table.Column(type: "datetimeoffset", nullable: false), + 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_NurseSearchIndices", x => x.Id); + table.ForeignKey( + name: "FK_NurseSearchIndices_NurseProfiles_NurseId", + column: x => x.NurseId, + principalSchema: "usr", + principalTable: "NurseProfiles", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_NurseSearchIndices_NurseServiceVariants_VariantId", + column: x => x.VariantId, + principalSchema: "catalog", + principalTable: "NurseServiceVariants", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_NurseSearchIndex_Search", + schema: "search", + table: "NurseSearchIndices", + columns: new[] { "IsSearchable", "ServiceCategoryId", "CityId", "DistrictId" }) + .Annotation("SqlServer:Include", new[] { "Price", "NurseGender", "AverageRating", "TotalReviews", "NurseId", "VariantId" }); + + migrationBuilder.CreateIndex( + name: "IX_NurseSearchIndices_NurseId", + schema: "search", + table: "NurseSearchIndices", + column: "NurseId"); + + migrationBuilder.CreateIndex( + name: "UX_NurseSearchIndex_Variant_City_District", + schema: "search", + table: "NurseSearchIndices", + columns: new[] { "VariantId", "CityId", "DistrictId" }, + unique: true, + filter: "[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL"); + + migrationBuilder.CreateIndex( + name: "UX_NurseSearchIndex_Variant_City_WholeCity", + schema: "search", + table: "NurseSearchIndices", + columns: new[] { "VariantId", "CityId" }, + unique: true, + filter: "[DistrictId] IS NULL AND [DeletedAt] IS NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "NurseSearchIndices", + schema: "search"); + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs index 158fb09..c106f36 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -2138,6 +2138,94 @@ namespace Baya.Infrastructure.Persistence.Migrations 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") @@ -3181,6 +3269,23 @@ namespace Baya.Infrastructure.Persistence.Migrations .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) diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs index 67bc622..c525f98 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs @@ -5,6 +5,7 @@ using Baya.Application.Contracts.Configuration; using Baya.Application.Contracts.Holidays; using Baya.Application.Contracts.Notifications; using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.Search; using Baya.Application.Contracts.SupportAlerts; using Baya.Infrastructure.Persistence.Interceptors; using Baya.Infrastructure.Persistence.Repositories.Common; @@ -13,6 +14,7 @@ using Baya.Infrastructure.Persistence.Services.Audit; using Baya.Infrastructure.Persistence.Services.Configuration; using Baya.Infrastructure.Persistence.Services.Holidays; using Baya.Infrastructure.Persistence.Services.Notifications; +using Baya.Infrastructure.Persistence.Services.Search; using Baya.Infrastructure.Persistence.Services.SupportAlerts; using Microsoft.AspNetCore.Builder; using Microsoft.EntityFrameworkCore; @@ -51,6 +53,18 @@ public static class ServiceCollectionExtensions // Retention job seam (mock = in-process interval runner; real Hangfire/Quartz deferred). 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. + services.AddScoped(); + + var searchBackend = configuration["Search:Backend"]; + if (string.IsNullOrWhiteSpace(searchBackend) || searchBackend.Equals("sql", StringComparison.OrdinalIgnoreCase)) + services.AddScoped(); + else + throw new NotSupportedException( + $"Search backend '{searchBackend}' is not available — only 'sql' is implemented (Elasticsearch is deferred)."); + return services; } diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Search/SearchIndexMaintainer.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Search/SearchIndexMaintainer.cs new file mode 100644 index 0000000..935b201 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Search/SearchIndexMaintainer.cs @@ -0,0 +1,285 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Search; +using Baya.Application.Models.Search; +using Baya.Domain.Entities.Catalog; +using Baya.Domain.Entities.Geography; +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.Search; +using Baya.Domain.Entities.Verification; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Infrastructure.Persistence.Services.Search; + +/// +/// The inline SQL implementation of . It shares the request-scoped +/// with the calling handler's IUnitOfWork, so it only stages +/// index changes — the handler's single CommitAsync flushes source + projection in one transaction. +/// ( is the exception: a standalone job that owns its own batched commits.) +/// +/// The visibility gate is recomputed on every call: a row is searchable only when the nurse is verified, +/// not suspended, accepting bookings, and the variant is active. Each (variant × area) has exactly one live +/// row; a soft-deleted row is resurrected on re-upsert rather than duplicated. +/// +/// +internal sealed class SearchIndexMaintainer(ApplicationDbContext db, IDateTimeProvider clock) : ISearchIndexMaintainer +{ + public async Task ReindexVariantAsync(NurseServiceVariant variant, CancellationToken cancellationToken) + { + var ctx = await LoadNurseContextAsync(variant.NurseId, cancellationToken); + if (ctx is null) + return; + + var status = await LoadStatusAsync(variant.NurseId, cancellationToken); + var bookable = NurseBookable(ctx.IsVerified, ctx.IsAcceptingBookings, status); + var areas = await LoadActiveAreasAsync(variant.NurseId, cancellationToken); + var vk = new VariantKey(variant.Id, variant.ServiceCategoryId, variant.Price, variant.PriceUnit, variant.IsActive); + + foreach (var area in areas) + await UpsertRowAsync(variant, vk, area, variant.NurseId, ctx, bookable, cancellationToken); + + // Reconcile: soft-delete this variant's live rows whose area the nurse no longer covers. + if (variant.Id != 0) + { + var live = await db.Set() + .Where(r => r.VariantId == variant.Id) + .ToListAsync(cancellationToken); + var covered = areas.ToHashSet(); + foreach (var row in live) + if (!covered.Contains(new AreaKey(row.CityId, row.DistrictId))) + SoftDelete(row); + } + } + + public async Task ReindexNurseAsync(NurseProfile profile, VerificationStatus? verificationStatus, CancellationToken cancellationToken) + { + var status = verificationStatus ?? await LoadStatusAsync(profile.Id, cancellationToken); + var gender = await LoadGenderAsync(profile.Id, cancellationToken); + + // Bookability + aggregates come from the tracked profile (its just-changed values); gender is stable + // for these triggers so it is read from the database. + var ctx = new NurseContext( + profile.IsVerified, profile.IsAcceptingBookings, gender, + profile.AverageRating, profile.TotalReviews, profile.TotalCompletedBookings); + var bookable = NurseBookable(profile.IsVerified, profile.IsAcceptingBookings, status); + + var areas = await LoadActiveAreasAsync(profile.Id, cancellationToken); + var variants = await LoadVariantsAsync(profile.Id, cancellationToken); + + var target = new HashSet<(long VariantId, long CityId, long? DistrictId)>(); + foreach (var variant in variants) + foreach (var area in areas) + { + await UpsertRowAsync(null, variant, area, profile.Id, ctx, bookable, cancellationToken); + target.Add((variant.Id, area.CityId, area.DistrictId)); + } + + // Prune live rows no longer derivable (variant deleted / area removed). + var liveRows = await db.Set() + .Where(r => r.NurseId == profile.Id) + .ToListAsync(cancellationToken); + foreach (var row in liveRows) + if (!target.Contains((row.VariantId, row.CityId, row.DistrictId))) + SoftDelete(row); + } + + public async Task FanOutServiceAreaAsync(long nurseId, long cityId, long? districtId, CancellationToken cancellationToken) + { + var ctx = await LoadNurseContextAsync(nurseId, cancellationToken); + if (ctx is null) + return; + + var status = await LoadStatusAsync(nurseId, cancellationToken); + var bookable = NurseBookable(ctx.IsVerified, ctx.IsAcceptingBookings, status); + var variants = await LoadVariantsAsync(nurseId, cancellationToken); + var area = new AreaKey(cityId, districtId); + + foreach (var variant in variants) + await UpsertRowAsync(null, variant, area, nurseId, ctx, bookable, cancellationToken); + } + + public async Task RemoveServiceAreaRowsAsync(long nurseId, long cityId, long? districtId, CancellationToken cancellationToken) + { + var rows = await db.Set() + .Where(r => r.NurseId == nurseId && r.CityId == cityId && r.DistrictId == districtId) + .ToListAsync(cancellationToken); + foreach (var row in rows) + SoftDelete(row); + } + + public async Task RebuildAsync(CancellationToken cancellationToken) + { + // Idempotent full rebuild: drop the whole projection, then re-derive from source in nurse-batches. + await db.Set().IgnoreQueryFilters().ExecuteDeleteAsync(cancellationToken); + + const int batchSize = 200; + var pageIndex = 0; + var nursesProcessed = 0; + var rowsWritten = 0; + + while (true) + { + var nurseIds = await db.Set() + .OrderBy(p => p.Id) + .Skip(pageIndex * batchSize) + .Take(batchSize) + .Select(p => p.Id) + .ToListAsync(cancellationToken); + if (nurseIds.Count == 0) + break; + + foreach (var nurseId in nurseIds) + { + rowsWritten += await BuildFreshRowsForNurseAsync(nurseId, cancellationToken); + nursesProcessed++; + } + + await db.SaveChangesAsync(cancellationToken); + pageIndex++; + } + + return new SearchIndexRebuildResult(nursesProcessed, rowsWritten); + } + + private async Task BuildFreshRowsForNurseAsync(long nurseId, CancellationToken cancellationToken) + { + var ctx = await LoadNurseContextAsync(nurseId, cancellationToken); + if (ctx is null) + return 0; + + var status = await LoadStatusAsync(nurseId, cancellationToken); + var bookable = NurseBookable(ctx.IsVerified, ctx.IsAcceptingBookings, status); + var areas = await LoadActiveAreasAsync(nurseId, cancellationToken); + var variants = await LoadVariantsAsync(nurseId, cancellationToken); + + var count = 0; + foreach (var variant in variants) + foreach (var area in areas) + { + await db.Set().AddAsync( + NewRow(null, variant, area, nurseId, ctx, bookable && variant.IsActive), cancellationToken); + count++; + } + + return count; + } + + private async Task UpsertRowAsync( + NurseServiceVariant? variantEntity, + VariantKey variant, + AreaKey area, + long nurseId, + NurseContext ctx, + bool nurseBookable, + CancellationToken cancellationToken) + { + var isSearchable = nurseBookable && variant.IsActive; + + // A new variant (id 0) can have no existing rows; otherwise look past the soft-delete filter so a + // previously-removed (variant × area) row is resurrected rather than duplicated. + var existing = variant.Id == 0 + ? null + : await db.Set() + .IgnoreQueryFilters() + .FirstOrDefaultAsync( + r => r.VariantId == variant.Id && r.CityId == area.CityId && r.DistrictId == area.DistrictId, + cancellationToken); + + if (existing is null) + { + await db.Set().AddAsync( + NewRow(variantEntity, variant, area, nurseId, ctx, isSearchable), cancellationToken); + return; + } + + existing.NurseId = nurseId; + existing.ServiceCategoryId = variant.ServiceCategoryId; + existing.Price = variant.Price; + existing.PriceUnit = variant.PriceUnit; + existing.NurseGender = ctx.Gender; + existing.AverageRating = ctx.AverageRating; + existing.TotalReviews = ctx.TotalReviews; + existing.TotalCompletedBookings = ctx.TotalCompletedBookings; + existing.IsSearchable = isSearchable; + existing.UpdatedAt = clock.UtcNow; + existing.DeletedAt = null; + } + + private NurseSearchIndex NewRow( + NurseServiceVariant? variantEntity, VariantKey variant, AreaKey area, long nurseId, NurseContext ctx, bool isSearchable) + { + var row = new NurseSearchIndex + { + VariantId = variant.Id, + NurseId = nurseId, + ServiceCategoryId = variant.ServiceCategoryId, + Price = variant.Price, + PriceUnit = variant.PriceUnit, + CityId = area.CityId, + DistrictId = area.DistrictId, + NurseGender = ctx.Gender, + AverageRating = ctx.AverageRating, + TotalReviews = ctx.TotalReviews, + TotalCompletedBookings = ctx.TotalCompletedBookings, + IsSearchable = isSearchable, + UpdatedAt = clock.UtcNow + }; + + // For a freshly-created variant the id is not assigned yet — attach the tracked principal so EF sets + // the generated variant_id in the same graph insert. + if (variant.Id == 0 && variantEntity is not null) + row.Variant = variantEntity; + + return row; + } + + private void SoftDelete(NurseSearchIndex row) + { + row.DeletedAt = clock.UtcNow; + row.IsSearchable = false; + row.UpdatedAt = clock.UtcNow; + } + + private static bool NurseBookable(bool isVerified, bool isAccepting, VerificationStatus? status) + => isVerified && isAccepting && status != VerificationStatus.Suspended; + + private Task LoadNurseContextAsync(long nurseId, CancellationToken cancellationToken) + => db.Set() + .Where(p => p.Id == nurseId) + .Select(p => new NurseContext( + p.IsVerified, p.IsAcceptingBookings, p.User.Gender, + p.AverageRating, p.TotalReviews, p.TotalCompletedBookings)) + .FirstOrDefaultAsync(cancellationToken); + + private async Task LoadGenderAsync(long nurseId, CancellationToken cancellationToken) + => await db.Set() + .Where(p => p.Id == nurseId) + .Select(p => p.User.Gender) + .FirstOrDefaultAsync(cancellationToken) ?? string.Empty; + + private Task LoadStatusAsync(long nurseId, CancellationToken cancellationToken) + => db.Set() + .Where(v => v.NurseId == nurseId) + .Select(v => (VerificationStatus?)v.Status) + .FirstOrDefaultAsync(cancellationToken); + + private Task> LoadActiveAreasAsync(long nurseId, CancellationToken cancellationToken) + => db.Set() + .Where(a => a.NurseId == nurseId && a.IsActive) + .Select(a => new AreaKey(a.CityId, a.DistrictId)) + .ToListAsync(cancellationToken); + + private Task> LoadVariantsAsync(long nurseId, CancellationToken cancellationToken) + => db.Set() + .Where(v => v.NurseId == nurseId) + .Select(v => new VariantKey(v.Id, v.ServiceCategoryId, v.Price, v.PriceUnit, v.IsActive)) + .ToListAsync(cancellationToken); + + private readonly record struct AreaKey(long CityId, long? DistrictId); + + private readonly record struct VariantKey(long Id, long ServiceCategoryId, long Price, string PriceUnit, bool IsActive); + + private sealed record NurseContext( + bool IsVerified, bool IsAcceptingBookings, string Gender, + decimal AverageRating, int TotalReviews, int TotalCompletedBookings); +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Search/SqlNurseSearch.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Search/SqlNurseSearch.cs new file mode 100644 index 0000000..e9ef692 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Search/SqlNurseSearch.cs @@ -0,0 +1,72 @@ +#nullable enable +using System.Globalization; +using Baya.Application.Contracts.Search; +using Baya.Application.Models.Common; +using Baya.Application.Models.Search; +using Baya.Domain.Entities.Search; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Infrastructure.Persistence.Services.Search; + +/// +/// The MVP backend — the real, production search over the maintained +/// nurse_search_index. It reads only is_searchable = 1 rows (an unverified, suspended, +/// paused, or deactivated nurse/variant never surfaces), applies the category/city/district/gender/price +/// filters and the rating sort, and paginates. Served from the covering search index; a later +/// ElasticNurseSearch replaces this class behind the same interface with no caller changes. +/// +internal sealed class SqlNurseSearch(ApplicationDbContext db) : INurseSearch +{ + public async Task> SearchAsync(NurseSearchCriteria criteria, CancellationToken cancellationToken) + { + var query = db.Set() + .AsNoTracking() + .Where(r => r.IsSearchable + && r.ServiceCategoryId == criteria.ServiceCategoryId + && r.CityId == criteria.CityId); + + // NULL-district = "whole city". A district search matches that district's rows PLUS the whole-city + // (NULL) rows; a city-only search (no district) matches every row in the city, NULL or not. + if (criteria.DistrictId is { } districtId) + query = query.Where(r => r.DistrictId == districtId || r.DistrictId == null); + + if (!string.IsNullOrWhiteSpace(criteria.NurseGender)) + query = query.Where(r => r.NurseGender == criteria.NurseGender); + + if (criteria.MinPrice is { } min) + query = query.Where(r => r.Price >= min); + + if (criteria.MaxPrice is { } max) + query = query.Where(r => r.Price <= max); + + if (!string.IsNullOrWhiteSpace(criteria.PriceUnit)) + query = query.Where(r => r.PriceUnit == criteria.PriceUnit); + + var total = await query.CountAsync(cancellationToken); + + var rows = await query + // Rating sort is the only MVP sort; the tiebreak on reviews then nurse_id keeps paging deterministic. + .OrderByDescending(r => r.AverageRating) + .ThenByDescending(r => r.TotalReviews) + .ThenBy(r => r.NurseId) + .ThenBy(r => r.VariantId) + .Skip((criteria.Page - 1) * criteria.PageSize) + .Take(criteria.PageSize) + .Select(r => new Row( + r.VariantId, r.NurseId, r.ServiceCategoryId, r.Price, r.PriceUnit, r.NurseGender, + r.AverageRating, r.TotalReviews, r.TotalCompletedBookings, r.CityId, r.DistrictId)) + .ToListAsync(cancellationToken); + + // Format price to a digit string in memory (no long.ToString translation required in SQL). + var items = rows.Select(r => new NurseSearchResultDto( + r.VariantId, r.NurseId, r.ServiceCategoryId, + r.Price.ToString(CultureInfo.InvariantCulture), r.PriceUnit, r.NurseGender, + r.AverageRating, r.TotalReviews, r.TotalCompletedBookings, r.CityId, r.DistrictId)).ToList(); + + return new PagedResult(items, total, criteria.Page, criteria.PageSize); + } + + private sealed record Row( + long VariantId, long NurseId, long ServiceCategoryId, long Price, string PriceUnit, string NurseGender, + decimal AverageRating, int TotalReviews, int TotalCompletedBookings, long CityId, long? DistrictId); +} diff --git a/server/src/Tests/Baya.Test.Api/SearchApiTests.cs b/server/src/Tests/Baya.Test.Api/SearchApiTests.cs new file mode 100644 index 0000000..530fff9 --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/SearchApiTests.cs @@ -0,0 +1,53 @@ +using System.Net; + +namespace Baya.Test.Api; + +public class SearchApiTests(BayaApiFactory factory) : IClassFixture +{ + private const long TehranCityId = 101; + + [Fact] + public async Task Search_Public_ReturnsPagedEnvelope() + { + var client = factory.CreateClient(); + + // Public (no auth). Nothing seeded matches, so the search returns an empty, well-formed page. + var response = await client.GetAsync($"/api/v1/search/nurses?service_category_id=1&city_id={TehranCityId}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var data = await AuthTestClient.ReadDataAsync(response); + Assert.Equal(0, data.GetProperty("total").GetInt32()); + Assert.Equal(0, data.GetProperty("items").GetArrayLength()); + } + + [Fact] + public async Task Search_MissingRequiredCategory_Returns400() + { + var client = factory.CreateClient(); + + var response = await client.GetAsync($"/api/v1/search/nurses?service_category_id=0&city_id={TehranCityId}"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Search_InvalidGender_Returns400() + { + var client = factory.CreateClient(); + + var response = await client.GetAsync( + $"/api/v1/search/nurses?service_category_id=1&city_id={TehranCityId}&nurse_gender=other"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task RebuildIndex_Unauthenticated_Returns401() + { + var client = factory.CreateClient(); + + var response = await client.PostAsync("/api/v1/admin_search/rebuild_index", null); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Catalog/CreateVariantHandlerTests.cs b/server/src/Tests/Baya.Test.Foundation/Catalog/CreateVariantHandlerTests.cs index d33fd14..75e42f3 100644 --- a/server/src/Tests/Baya.Test.Foundation/Catalog/CreateVariantHandlerTests.cs +++ b/server/src/Tests/Baya.Test.Foundation/Catalog/CreateVariantHandlerTests.cs @@ -1,5 +1,6 @@ using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.Search; using Baya.Application.Features.Variants.Commands.CreateVariant; using Baya.Application.Models.Catalog; using Baya.Domain.Entities.Catalog; @@ -16,6 +17,7 @@ public class CreateVariantHandlerTests private readonly INurseProfileRepository _nurses = Substitute.For(); private readonly ICatalogRepository _catalog = Substitute.For(); private readonly INurseServiceVariantRepository _variants = Substitute.For(); + private readonly ISearchIndexMaintainer _searchIndex = Substitute.For(); private const long CategoryId = 1; private const long ShiftGroupId = 10; @@ -50,7 +52,7 @@ public class CreateVariantHandlerTests .Returns(false); } - private CreateVariantCommandHandler Handler() => new(_currentUser, _unitOfWork); + private CreateVariantCommandHandler Handler() => new(_currentUser, _unitOfWork, _searchIndex); private static CreateVariantCommand Command(IReadOnlyList options, string? displayName = null) => new(CategoryId, options, "8000000", "per_24h", null, displayName); diff --git a/server/src/Tests/Baya.Test.Foundation/Geography/NurseServiceAreaHandlersTests.cs b/server/src/Tests/Baya.Test.Foundation/Geography/NurseServiceAreaHandlersTests.cs index 7d2265f..3dba41b 100644 --- a/server/src/Tests/Baya.Test.Foundation/Geography/NurseServiceAreaHandlersTests.cs +++ b/server/src/Tests/Baya.Test.Foundation/Geography/NurseServiceAreaHandlersTests.cs @@ -1,5 +1,6 @@ using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.Search; using Baya.Application.Features.ServiceAreas.Commands.AddNurseServiceArea; using Baya.Application.Features.ServiceAreas.Commands.RemoveNurseServiceArea; using Baya.Domain.Entities.Geography; @@ -16,6 +17,7 @@ public class NurseServiceAreaHandlersTests private readonly INurseProfileRepository _nurses = Substitute.For(); private readonly IGeoRepository _geo = Substitute.For(); private readonly INurseServiceAreaRepository _areas = Substitute.For(); + private readonly ISearchIndexMaintainer _searchIndex = Substitute.For(); public NurseServiceAreaHandlersTests() { @@ -34,7 +36,7 @@ public class NurseServiceAreaHandlersTests public async Task Add_WholeCity_PersistsWholeCityRow() { _areas.DuplicateExistsAsync(42L, 101L, null, Arg.Any()).Returns(false); - var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork); + var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, _searchIndex); var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, null), CancellationToken.None); @@ -50,7 +52,7 @@ public class NurseServiceAreaHandlersTests public async Task Add_DuplicateWholeCity_ReturnsConflictNotPersisted() { _areas.DuplicateExistsAsync(42L, 101L, null, Arg.Any()).Returns(true); - var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork); + var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, _searchIndex); var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, null), CancellationToken.None); @@ -66,7 +68,7 @@ public class NurseServiceAreaHandlersTests _geo.GetDistrictAsync(5L, Arg.Any()) .Returns(new District { NameFa = "منطقه ۱", NameEn = "District 1" }); _areas.DuplicateExistsAsync(42L, 101L, 5L, Arg.Any()).Returns(true); - var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork); + var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, _searchIndex); var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, 5L), CancellationToken.None); @@ -77,7 +79,7 @@ public class NurseServiceAreaHandlersTests public async Task Add_DistrictNotInCity_Fails() { _geo.IsDistrictInActiveCityAsync(999L, 101L, Arg.Any()).Returns(false); - var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork); + var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, _searchIndex); var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, 999L), CancellationToken.None); @@ -89,7 +91,7 @@ public class NurseServiceAreaHandlersTests public async Task Add_NonNurse_IsForbidden() { _currentUser.Roles.Returns([RoleNames.Customer]); - var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork); + var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, _searchIndex); var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, null), CancellationToken.None); @@ -100,7 +102,7 @@ public class NurseServiceAreaHandlersTests public async Task Remove_OtherNursesArea_IsNotFound() { _areas.GetOwnedAsync(99L, 42L, Arg.Any()).ReturnsNull(); - var handler = new RemoveNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, Substitute.For()); + var handler = new RemoveNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, Substitute.For(), _searchIndex); var result = await handler.Handle(new RemoveNurseServiceAreaCommand(99L), CancellationToken.None); diff --git a/server/src/Tests/Baya.Test.Foundation/Identity/NurseProfileHandlersTests.cs b/server/src/Tests/Baya.Test.Foundation/Identity/NurseProfileHandlersTests.cs index 5006eaf..88267fa 100644 --- a/server/src/Tests/Baya.Test.Foundation/Identity/NurseProfileHandlersTests.cs +++ b/server/src/Tests/Baya.Test.Foundation/Identity/NurseProfileHandlersTests.cs @@ -1,5 +1,6 @@ using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.Search; using Baya.Application.Features.Identity.Commands.SetNurseAcceptingBookings; using Baya.Application.Features.Identity.Commands.UpsertNurseProfile; using Baya.Application.Models.Identity; @@ -15,6 +16,7 @@ public class NurseProfileHandlersTests private readonly ICurrentUser _currentUser = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); private readonly INurseProfileRepository _repo = Substitute.For(); + private readonly ISearchIndexMaintainer _searchIndex = Substitute.For(); public NurseProfileHandlersTests() { @@ -58,7 +60,7 @@ public class NurseProfileHandlersTests public async Task SetAcceptingBookings_NoProfile_IsNotFound() { _repo.GetByUserIdAsync(7, Arg.Any()).ReturnsNull(); - var handler = new SetNurseAcceptingBookingsCommandHandler(_currentUser, _unitOfWork); + var handler = new SetNurseAcceptingBookingsCommandHandler(_currentUser, _unitOfWork, _searchIndex); var result = await handler.Handle(new SetNurseAcceptingBookingsCommand(true), CancellationToken.None); @@ -71,7 +73,7 @@ public class NurseProfileHandlersTests { var profile = new NurseProfile { UserId = 7 }; _repo.GetByUserIdAsync(7, Arg.Any()).Returns(profile); - var handler = new SetNurseAcceptingBookingsCommandHandler(_currentUser, _unitOfWork); + var handler = new SetNurseAcceptingBookingsCommandHandler(_currentUser, _unitOfWork, _searchIndex); var result = await handler.Handle(new SetNurseAcceptingBookingsCommand(true), CancellationToken.None); diff --git a/server/src/Tests/Baya.Test.Foundation/Search/SearchIndexTestHost.cs b/server/src/Tests/Baya.Test.Foundation/Search/SearchIndexTestHost.cs new file mode 100644 index 0000000..748a010 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Search/SearchIndexTestHost.cs @@ -0,0 +1,170 @@ +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Search; +using Baya.Domain.Entities.Catalog; +using Baya.Domain.Entities.Geography; +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.User; +using Baya.Domain.Entities.Verification; +using Baya.Infrastructure.Persistence; +using Baya.Infrastructure.Persistence.Services.Search; +using Baya.Tests.Setup.Setups; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using NSubstitute; + +namespace Baya.Test.Foundation.Search; + +/// +/// A self-contained SQLite host for the search-index maintainer + SqlNurseSearch, exercising the real EF +/// model (schema, filtered indexes, query filters) end-to-end. Seeds a shared province/city/two districts +/// and one active service category; builds a full nurse (user + profile + +/// verification + variant + area) so a test can drive the maintainer and assert what search returns. +/// +public sealed class SearchIndexTestHost : IDisposable +{ + public static readonly DateTimeOffset Now = new(2026, 7, 5, 12, 0, 0, TimeSpan.Zero); + + private readonly SqliteConnection _connection; + public ApplicationDbContext Db { get; } + public ISearchIndexMaintainer Maintainer { get; } + public INurseSearch Search { get; } + + public long CategoryId { get; } + public long OtherCategoryId { get; } + public long CityId { get; } + public long District3Id { get; } + public long District7Id { get; } + + private int _phoneSeed = 90000000; + + public SearchIndexTestHost() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + Db = new ApplicationDbContext(options, TestFieldEncryptor.Instance); + Db.Database.EnsureCreated(); + + var clock = Substitute.For(); + clock.UtcNow.Returns(Now); + Maintainer = new SearchIndexMaintainer(Db, clock); + Search = new SqlNurseSearch(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(); + CityId = city.Id; + + var d3 = new District { CityId = city.Id, NameFa = "منطقه ۳", NameEn = "District 3", SortOrder = 3, IsActive = true }; + var d7 = new District { CityId = city.Id, NameFa = "منطقه ۷", NameEn = "District 7", SortOrder = 7, IsActive = true }; + Db.Set().AddRange(d3, d7); + Db.SaveChanges(); + District3Id = d3.Id; + District7Id = d7.Id; + + var category = new ServiceCategory { NameFa = "سالمند", NameEn = "Elderly", SortOrder = 1, IsActive = true }; + var other = new ServiceCategory { NameFa = "نوزاد", NameEn = "Infant", SortOrder = 2, IsActive = true }; + Db.Set().AddRange(category, other); + Db.SaveChanges(); + CategoryId = category.Id; + OtherCategoryId = other.Id; + } + + public sealed record SeededNurse(long NurseId, NurseProfile Profile, NurseVerification Verification, NurseServiceVariant Variant, NurseServiceArea Area); + + /// Seeds one nurse with a single active variant + a single service area, in a chosen bookability + /// state, and does NOT project it yet (the caller drives the maintainer). + public SeededNurse SeedNurse( + string gender, + bool verified, + bool accepting, + VerificationStatus status, + long price, + long? districtId, + decimal averageRating = 0m, + int totalReviews = 0, + long? categoryId = null, + bool variantActive = true, + string priceUnit = "per_day") + { + var user = new User + { + UserName = $"nurse{_phoneSeed}", + PhoneNumber = $"0912{_phoneSeed++}", + Gender = gender, + IsActive = true + }; + Db.Users.Add(user); + Db.SaveChanges(); + + var profile = new NurseProfile { UserId = user.Id }; + if (verified) + profile.MarkVerified(); + profile.SetAcceptingBookings(accepting); + Db.Set().Add(profile); + SetAggregates(profile, averageRating, totalReviews); + Db.SaveChanges(); + + var verification = new NurseVerification { NurseId = profile.Id, Status = status }; + Db.Set().Add(verification); + Db.SaveChanges(); + + var variant = new NurseServiceVariant + { + NurseId = profile.Id, + ServiceCategoryId = categoryId ?? CategoryId, + Price = price, + PriceUnit = priceUnit, + SessionCount = null, + DisplayName = "variant", + OptionSetHash = $"hash-{profile.Id}", + IsActive = variantActive + }; + Db.Set().Add(variant); + Db.SaveChanges(); + + var area = new NurseServiceArea { NurseId = profile.Id, CityId = CityId, DistrictId = districtId, IsActive = true }; + Db.Set().Add(area); + Db.SaveChanges(); + + return new SeededNurse(profile.Id, profile, verification, variant, area); + } + + // The aggregate setters on NurseProfile are private (recomputed by b9/b14). Tests seed them via EF's + // backing fields so a nurse can carry a rating without the (not-yet-built) review pipeline. + private void SetAggregates(NurseProfile profile, decimal averageRating, int totalReviews) + { + var entry = Db.Entry(profile); + entry.Property(nameof(NurseProfile.AverageRating)).CurrentValue = averageRating; + entry.Property(nameof(NurseProfile.TotalReviews)).CurrentValue = totalReviews; + } + + /// Adds a real service-area row (as the b4 handler would) so a later fan-out and a full rebuild + /// derive from the same source. + public NurseServiceArea AddArea(long nurseId, long? districtId) + { + var area = new NurseServiceArea { NurseId = nurseId, CityId = CityId, DistrictId = districtId, IsActive = true }; + Db.Set().Add(area); + Db.SaveChanges(); + return area; + } + + public int LiveRowCount() => Db.Set().Count(); + + public int SearchableRowCount() => + Db.Set().Count(r => r.IsSearchable); + + public void Dispose() + { + Db.Dispose(); + _connection.Dispose(); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Search/SearchIndexTests.cs b/server/src/Tests/Baya.Test.Foundation/Search/SearchIndexTests.cs new file mode 100644 index 0000000..a244e41 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Search/SearchIndexTests.cs @@ -0,0 +1,219 @@ +using Baya.Application.Models.Search; +using Baya.Domain.Entities.Verification; + +namespace Baya.Test.Foundation.Search; + +/// +/// End-to-end coverage of the search-index maintainer + SqlNurseSearch over a real EF/SQLite model: the +/// is_searchable predicate, NULL-district geography, gender/price filters, rating sort, the verification +/// flip, service-area fan-out/remove, variant deactivate, and incremental↔rebuild convergence. +/// +public sealed class SearchIndexTests +{ + private const string Female = "female"; + private const string Male = "male"; + + private static NurseSearchCriteria Criteria( + long categoryId, long cityId, long? districtId = null, string? gender = null, + long? minPrice = null, long? maxPrice = null, string? priceUnit = null, int page = 1, int pageSize = 50) + => new(categoryId, cityId, districtId, gender, minPrice, maxPrice, priceUnit, page, pageSize); + + private static void Project(SearchIndexTestHost host, SearchIndexTestHost.SeededNurse nurse) + { + host.Maintainer.ReindexNurseAsync(nurse.Profile, nurse.Verification.Status, default).GetAwaiter().GetResult(); + host.Db.SaveChanges(); + } + + [Fact] + public void IsSearchable_TrueOnlyWhenVerifiedAcceptingNotSuspendedAndVariantActive() + { + using var host = new SearchIndexTestHost(); + + var good = host.SeedNurse(Female, verified: true, accepting: true, VerificationStatus.Approved, 1000, host.District3Id); + var unverified = host.SeedNurse(Female, verified: false, accepting: true, VerificationStatus.Pending, 1000, host.District3Id); + var notAccepting = host.SeedNurse(Female, verified: true, accepting: false, VerificationStatus.Approved, 1000, host.District3Id); + var suspended = host.SeedNurse(Female, verified: true, accepting: true, VerificationStatus.Suspended, 1000, host.District3Id); + var inactiveVariant = host.SeedNurse(Female, verified: true, accepting: true, VerificationStatus.Approved, 1000, host.District3Id, variantActive: false); + + foreach (var n in new[] { good, unverified, notAccepting, suspended, inactiveVariant }) + Project(host, n); + + Assert.True(IsSearchable(host, good.NurseId)); + Assert.False(IsSearchable(host, unverified.NurseId)); + Assert.False(IsSearchable(host, notAccepting.NurseId)); + Assert.False(IsSearchable(host, suspended.NurseId)); + Assert.False(IsSearchable(host, inactiveVariant.NurseId)); + + // Every nurse with a variant + area has an index row, but only the fully-bookable one is searchable. + Assert.Equal(5, host.LiveRowCount()); + Assert.Equal(1, host.SearchableRowCount()); + } + + [Fact] + public void Geography_WholeCityAndDistrictMatchingIsExact() + { + using var host = new SearchIndexTestHost(); + + var district3 = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id); + var wholeCity = host.SeedNurse(Male, true, true, VerificationStatus.Approved, 1000, districtId: null); + Project(host, district3); + Project(host, wholeCity); + + // District-3 search: the district-3 nurse AND the whole-city (NULL) nurse. + var d3 = host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId, host.District3Id), default).Result; + Assert.Equal(new[] { district3.NurseId, wholeCity.NurseId }.OrderBy(x => x), d3.Items.Select(i => i.NurseId).OrderBy(x => x)); + + // A different district in the same city: only the whole-city nurse. + var d7 = host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId, host.District7Id), default).Result; + Assert.Equal(new[] { wholeCity.NurseId }, d7.Items.Select(i => i.NurseId).ToArray()); + + // City-only search (no district): both. + var city = host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId), default).Result; + Assert.Equal(2, city.Total); + } + + [Fact] + public void SameGenderFilterNarrowsResults() + { + using var host = new SearchIndexTestHost(); + var female = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id); + var male = host.SeedNurse(Male, true, true, VerificationStatus.Approved, 1000, host.District3Id); + Project(host, female); + Project(host, male); + + var females = host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId, gender: Female), default).Result; + Assert.Equal(new[] { female.NurseId }, females.Items.Select(i => i.NurseId).ToArray()); + + var males = host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId, gender: Male), default).Result; + Assert.Equal(new[] { male.NurseId }, males.Items.Select(i => i.NurseId).ToArray()); + } + + [Fact] + public void PriceRangeFiltersOnCopiedIrrPrice() + { + using var host = new SearchIndexTestHost(); + var cheap = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 500_000, host.District3Id); + var pricey = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 5_000_000, host.District3Id); + Project(host, cheap); + Project(host, pricey); + + var midBand = host.Search.SearchAsync( + Criteria(host.CategoryId, host.CityId, minPrice: 400_000, maxPrice: 1_000_000), default).Result; + + Assert.Equal(new[] { cheap.NurseId }, midBand.Items.Select(i => i.NurseId).ToArray()); + Assert.Equal("500000", midBand.Items[0].Price); + } + + [Fact] + public void ResultsSortByRatingDescending() + { + using var host = new SearchIndexTestHost(); + var low = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id, averageRating: 3.1m, totalReviews: 4); + var high = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id, averageRating: 4.8m, totalReviews: 9); + Project(host, low); + Project(host, high); + + var page = host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId), default).Result; + + Assert.Equal(new[] { high.NurseId, low.NurseId }, page.Items.Select(i => i.NurseId).ToArray()); + } + + [Fact] + public void SuspendingANurseRemovesThemFromSearch() + { + using var host = new SearchIndexTestHost(); + var nurse = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id); + Project(host, nurse); + Assert.Single(host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId), default).Result.Items); + + // Flip to suspended + unverified (the b6 suspend path) and reindex in place. + nurse.Profile.MarkUnverified(); + nurse.Verification.Status = VerificationStatus.Suspended; + Project(host, nurse); + + Assert.Empty(host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId), default).Result.Items); + Assert.False(IsSearchable(host, nurse.NurseId)); + + // Reinstating makes them searchable again — the row is resurrected, not duplicated. + nurse.Profile.MarkVerified(); + nurse.Verification.Status = VerificationStatus.Approved; + Project(host, nurse); + Assert.Single(host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId), default).Result.Items); + Assert.Equal(1, host.LiveRowCount()); + } + + [Fact] + public void FanOutAddsAreaRows_RemoveDropsExactlyThoseRows() + { + using var host = new SearchIndexTestHost(); + var nurse = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id); + Project(host, nurse); + Assert.Equal(1, host.LiveRowCount()); + + // Add a second area (district 7) and fan out. + host.Maintainer.FanOutServiceAreaAsync(nurse.NurseId, host.CityId, host.District7Id, default).GetAwaiter().GetResult(); + host.Db.SaveChanges(); + Assert.Equal(2, host.SearchableRowCount()); + Assert.Single(host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId, host.District7Id), default).Result.Items); + + // Remove the district-7 area: only its rows drop; district 3 stays. + host.Maintainer.RemoveServiceAreaRowsAsync(nurse.NurseId, host.CityId, host.District7Id, default).GetAwaiter().GetResult(); + host.Db.SaveChanges(); + Assert.Empty(host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId, host.District7Id), default).Result.Items); + Assert.Single(host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId, host.District3Id), default).Result.Items); + } + + [Fact] + public void DeactivatingAVariantKeepsRowsButHidesThem() + { + using var host = new SearchIndexTestHost(); + var nurse = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id); + Project(host, nurse); + Assert.Equal(1, host.SearchableRowCount()); + + nurse.Variant.IsActive = false; + host.Maintainer.ReindexVariantAsync(nurse.Variant, default).GetAwaiter().GetResult(); + host.Db.SaveChanges(); + + Assert.Equal(1, host.LiveRowCount()); // row kept + Assert.Equal(0, host.SearchableRowCount()); // but not searchable + Assert.Empty(host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId), default).Result.Items); + } + + [Fact] + public void IncrementalMaintenanceConvergesWithFullRebuild() + { + using var host = new SearchIndexTestHost(); + + var a = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id, averageRating: 4.5m); + var b = host.SeedNurse(Male, true, true, VerificationStatus.Approved, 2000, districtId: null); + var c = host.SeedNurse(Female, false, true, VerificationStatus.Pending, 3000, host.District7Id); + Project(host, a); + Project(host, b); + Project(host, c); + // Add a second area to B incrementally — the real service-area row plus the fan-out, as b4 does. + host.AddArea(b.NurseId, host.District3Id); + host.Maintainer.FanOutServiceAreaAsync(b.NurseId, host.CityId, host.District3Id, default).GetAwaiter().GetResult(); + host.Db.SaveChanges(); + + var incrementalLive = host.LiveRowCount(); + var incrementalSearchable = host.SearchableRowCount(); + + // A full rebuild from source must reproduce the same live/searchable row set (convergence). + var result = host.Maintainer.RebuildAsync(default).GetAwaiter().GetResult(); + + Assert.Equal(incrementalLive, host.LiveRowCount()); + Assert.Equal(incrementalSearchable, host.SearchableRowCount()); + Assert.Equal(3, result.NursesProcessed); + Assert.Equal(incrementalLive, result.RowsWritten); + + // No duplicate (variant × area) rows after rebuild. + var duplicates = host.Db.Set() + .GroupBy(r => new { r.VariantId, r.CityId, r.DistrictId }) + .Any(g => g.Count() > 1); + Assert.False(duplicates); + } + + private static bool IsSearchable(SearchIndexTestHost host, long nurseId) + => host.Db.Set().Any(r => r.NurseId == nurseId && r.IsSearchable); +} diff --git a/server/src/Tests/Baya.Test.Foundation/Verification/AdminVerificationHandlersTests.cs b/server/src/Tests/Baya.Test.Foundation/Verification/AdminVerificationHandlersTests.cs index 3ac186f..e389f28 100644 --- a/server/src/Tests/Baya.Test.Foundation/Verification/AdminVerificationHandlersTests.cs +++ b/server/src/Tests/Baya.Test.Foundation/Verification/AdminVerificationHandlersTests.cs @@ -1,6 +1,7 @@ using Baya.Application.Contracts.Audit; using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.Search; using Baya.Application.Contracts.SupportAlerts; using Baya.Application.Features.Verification.Commands.ReviewStep; using Baya.Application.Features.Verification.Commands.ScanExpiringCredentials; @@ -25,6 +26,7 @@ public class AdminVerificationHandlersTests private readonly ICacheService _cache = Substitute.For(); private readonly IAuditLogger _audit = Substitute.For(); private readonly IDateTimeProvider _clock = Substitute.For(); + private readonly ISearchIndexMaintainer _searchIndex = Substitute.For(); public AdminVerificationHandlersTests() { @@ -44,7 +46,7 @@ public class AdminVerificationHandlersTests } private AdminReviewStepCommandHandler ReviewHandler(ICredentialVerifier credentialVerifier) - => new(_currentUser, _unitOfWork, credentialVerifier, _audit, _cache, _clock); + => new(_currentUser, _unitOfWork, credentialVerifier, _audit, _cache, _clock, _searchIndex); [Fact] public async Task Review_ApproveCredentialStep_RecordsCredentialAndFlipsVerified() @@ -121,7 +123,7 @@ public class AdminVerificationHandlersTests var profile = new NurseProfile(); profile.MarkVerified(); _nurses.GetTrackedByIdAsync(42, Arg.Any()).Returns(profile); - var handler = new AdminSuspendVerificationCommandHandler(_currentUser, _unitOfWork, _audit, _cache, _clock); + var handler = new AdminSuspendVerificationCommandHandler(_currentUser, _unitOfWork, _audit, _cache, _clock, _searchIndex); var result = await handler.Handle(new AdminSuspendVerificationCommand(10, "Fraud reported"), CancellationToken.None); @@ -151,7 +153,7 @@ public class AdminVerificationHandlersTests var alerts = Substitute.For(); var notifications = Substitute.For(); - var handler = new ScanExpiringCredentialsCommandHandler(_unitOfWork, alerts, notifications, _cache, _clock); + var handler = new ScanExpiringCredentialsCommandHandler(_unitOfWork, alerts, notifications, _cache, _clock, _searchIndex); var result = await handler.Handle(new ScanExpiringCredentialsCommand(), CancellationToken.None); @@ -193,7 +195,7 @@ public class AdminVerificationHandlersTests _nurses.GetTrackedByIdAsync(99, Arg.Any()).Returns(profileB); var handler = new ScanExpiringCredentialsCommandHandler( - _unitOfWork, Substitute.For(), Substitute.For(), _cache, _clock); + _unitOfWork, Substitute.For(), Substitute.For(), _cache, _clock, _searchIndex); var result = await handler.Handle(new ScanExpiringCredentialsCommand(), CancellationToken.None); diff --git a/server/src/Tests/Baya.Test.Foundation/Verification/RunStepHandlersTests.cs b/server/src/Tests/Baya.Test.Foundation/Verification/RunStepHandlersTests.cs index 7ce8334..7ddaa79 100644 --- a/server/src/Tests/Baya.Test.Foundation/Verification/RunStepHandlersTests.cs +++ b/server/src/Tests/Baya.Test.Foundation/Verification/RunStepHandlersTests.cs @@ -1,5 +1,6 @@ using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.Search; using Baya.Application.Contracts.SupportAlerts; using Baya.Application.Features.Verification.Commands.RunBankAccountVerification; using Baya.Application.Features.Verification.Commands.RunIdentityKyc; @@ -24,6 +25,7 @@ public class RunStepHandlersTests private readonly INurseProfileRepository _nurses = Substitute.For(); private readonly INurseBankAccountRepository _accounts = Substitute.For(); private readonly IDateTimeProvider _clock = Substitute.For(); + private readonly ISearchIndexMaintainer _searchIndex = Substitute.For(); public RunStepHandlersTests() { @@ -54,7 +56,7 @@ public class RunStepHandlersTests var identityKyc = Substitute.For(); identityKyc.VerifyAsync("0012345678", null, Arg.Any()) .Returns(new IdentityKycResult(true, "Verified Nurse", "ref", "{}", null)); - var handler = new RunIdentityKycCommandHandler(_currentUser, _unitOfWork, identityKyc, _clock); + var handler = new RunIdentityKycCommandHandler(_currentUser, _unitOfWork, identityKyc, _clock, _searchIndex); var result = await handler.Handle(new RunIdentityKycCommand("0012345678", null), CancellationToken.None); @@ -75,7 +77,7 @@ public class RunStepHandlersTests var identityKyc = Substitute.For(); identityKyc.VerifyAsync("0000000000", null, Arg.Any()) .Returns(new IdentityKycResult(false, null, "ref", "{}", "could not verify")); - var handler = new RunIdentityKycCommandHandler(_currentUser, _unitOfWork, identityKyc, _clock); + var handler = new RunIdentityKycCommandHandler(_currentUser, _unitOfWork, identityKyc, _clock, _searchIndex); var result = await handler.Handle(new RunIdentityKycCommand("0000000000", null), CancellationToken.None); @@ -96,7 +98,7 @@ public class RunStepHandlersTests shahkar.MatchAsync("09120000000", "0012345678", Arg.Any()) .Returns(new ShahkarMatchResult(false, true, "ref", "{}", "shared sim")); var alerts = Substitute.For(); - var handler = new RunShahkarMatchCommandHandler(_currentUser, _unitOfWork, shahkar, alerts, _clock); + var handler = new RunShahkarMatchCommandHandler(_currentUser, _unitOfWork, shahkar, alerts, _clock, _searchIndex); var result = await handler.Handle(new RunShahkarMatchCommand(), CancellationToken.None); @@ -115,7 +117,7 @@ public class RunStepHandlersTests _verif.GetTrackedUserAsync(7, Arg.Any()).Returns(new User { PhoneNumber = "09121112233" }); var shahkar = Substitute.For(); var alerts = Substitute.For(); - var handler = new RunShahkarMatchCommandHandler(_currentUser, _unitOfWork, shahkar, alerts, _clock); + var handler = new RunShahkarMatchCommandHandler(_currentUser, _unitOfWork, shahkar, alerts, _clock, _searchIndex); var result = await handler.Handle(new RunShahkarMatchCommand(), CancellationToken.None); @@ -135,7 +137,7 @@ public class RunStepHandlersTests var verifier = Substitute.For(); verifier.VerifyOwnershipAsync(account.Iban, "0012345678", Arg.Any()) .Returns(new OwnershipInquiryResult(false, "Someone Else", "ref")); - var handler = new RunBankAccountVerificationCommandHandler(_currentUser, _unitOfWork, verifier, _clock); + var handler = new RunBankAccountVerificationCommandHandler(_currentUser, _unitOfWork, verifier, _clock, _searchIndex); var result = await handler.Handle(new RunBankAccountVerificationCommand(), CancellationToken.None);