# 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.