# 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), `pageSize` (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 `pageSize > 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`.

---

## Refinement phase 3 additions (REQ-012)

- **`NurseSearchResultDto`** gains `nurseName` + `avatarUrl` (denormalized onto `nurse_search_index`, so no
  per-row join) and `distanceKm` (nullable — the covering index carries no coordinate, so it is null today).
- **`GET api/v1/nurses/{id}/profile`** (public) — the aggregated discovery detail:
  `{ nurseId, nurseName, avatarUrl, bio, yearsExperience, averageRating, totalReviews,
  totalCompletedBookings, isVerified, inoMembership, attributeChips[], services: [{ variantId, displayName,
  priceIrr, priceUnit, sessionCount? }], latestReview?: { rating, body, authorMasked (null by design),
  createdAt } }`. No encrypted credential number is ever exposed.
