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:
@@ -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`.
|
||||
@@ -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 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 | 🟡 |
|
||||
|
||||
Reference in New Issue
Block a user