Files
baya-monorepo/dev/contracts/domains/search.md
T
hamid 5839b3508f 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>
2026-07-05 17:24:26 +03:30

106 lines
5.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.