118 lines
6.6 KiB
Markdown
118 lines
6.6 KiB
Markdown
# 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.
|