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": [
{