backend phase 7: search & matching (nurse_search_index)

Add the discovery layer: the denormalized nurse_search_index read model
(one row per bookable variant x covered service area), maintained inline
inside each source write's transaction, plus the single public search
query behind the INurseSearch seam.

- Entity + EF config + migration (search schema): covering search index,
  filtered-unique (variant_id, city_id, district_id) pair with NULL
  district participating, nurse_id index, soft-delete.
- ISearchIndexMaintainer (write seam) + SearchIndexMaintainer: reindex
  variant / nurse / fan-out / remove-area / full rebuild, staged in the
  owning source write's unit of work; wired into the b3/b4/b5/b6 handlers.
- INurseSearch (read seam) + SqlNurseSearch (real MVP backend): reads only
  is_searchable=1, category/city/district(NULL-aware)/gender/price filters,
  rating sort, pagination. Elasticsearch deferred (config Search:Backend).
- SearchNursesQuery (+ validator) and RebuildSearchIndexCommand; public
  SearchController (GET search/nurses) + admin AdminSearchController
  (POST admin_search/rebuild_index).
- Tests: 9 DB-backed maintainer/search + 4 WebApplicationFactory; updated
  affected b3/b4/b5/b6 handler tests. Build clean, 167 tests green.
- Docs: server CLAUDE.md project map, contract search.md, swagger refresh,
  handoff, report, mocks-registry rows, STATUS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamid
2026-07-05 17:24:26 +03:30
parent e2b22df2d6
commit 5839b3508f
47 changed files with 5743 additions and 41 deletions
+105
View File
@@ -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`.
+347 -1
View File
@@ -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": [
{
@@ -12,6 +12,31 @@ One block per completed backend phase. Newest at the top. Backend lane writes he
- **Notes for frontend:** <anything load-bearing>
-->
## 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`
@@ -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<NurseSearchResultDto>` (`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.
@@ -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).
@@ -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 15, `sortOrder` 04). 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 | 🟡 |