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