# search — nurse discovery

> Client seam `client/src/services/search/` · `USE_SEARCH_MOCK = false` (**real**) · 2 server ops
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).

Two endpoints, both anonymous, both reading a **projected index** rather than joining live tables.

## Endpoints

| Method | Path | Auth | Verdict |
| --- | --- | --- | --- |
| GET | `/api/v1/search/nurses` | **anonymous** | wired · paginated — **`page` + `page_size`** |
| GET | `/api/v1/nurses/{nurseId}/profile` | **anonymous** | wired |

No phantoms. `POST /api/v1/admin_search/rebuild_index` is the index rebuild one-shot and lives in
[admin.md](admin.md); `nurses/{id}/reviews` and `/review_tags` are in [reviews.md](reviews.md);
`nurses/{id}/trust_badge` is in [verification.md](verification.md).

## The one endpoint with snake_case query params

`GET /api/v1/search/nurses` is the **only** endpoint whose query parameters are snake_case:

```
service_category_id  city_id  district_id  nurse_gender  min_price  max_price  page  page_size
```

`service_category_id` and `city_id` are required. This matters because it is also the only endpoint where
paging is declared as **`page_size`**, not `pageSize` — and model binding is case-insensitive, not
separator-insensitive, so sending `pageSize` here binds nothing and silently yields the default page size.
The client's `searchClientApi` already sends `page_size` correctly. See
[../api-contract.md](../api-contract.md#pagination).

## `is_searchable` — the four conditions

A nurse variant appears in results only when **all four** hold. Anything that flips one of them must
maintain the index in the same unit of work (`ISearchIndexMaintainer`):

1. the nurse's verification aggregate is `approved` — [verification.md](verification.md)
2. `isAcceptingBookings` is true — [profiles.md](profiles.md)
3. the variant is active — [catalog.md](catalog.md)
4. the nurse has at least one service area covering the queried city — [service-areas.md](service-areas.md)

**`districtId = null` means whole-city on both sides of the match.** A customer filtering by district *D*
must see a nurse whose area names *D* **and** a nurse whose area is whole-city. A query that drops the
nulls silently hides every whole-city nurse. See [geography.md](geography.md).

## Shape rules the JSON does not express

- **The index row is denormalised** (REQ-012, delivered): `nurseName`, `avatarUrl` and `distanceKm` are on
  `NurseSearchResultDto`, so the result card needs no per-row fetch. `price` is a **digit string**.
- **`distanceKm` is nullable** — null when either side has no resolved coordinates. The card omits the
  distance chip rather than showing 0.
- **`GET nurses/{id}/profile` aggregates** identity, bio, specialties, the full services list and the
  latest review into `NursePublicProfileDto` (REQ-012). One request builds the whole profile screen.
- **`attributeChips` is a server-composed display list**, not a code vocabulary — render it, do not map it.
- **`inoMembership` on the public profile is a boolean summary** of the INO verification step, not the step
  itself. The per-step detail is the still-open REQ-043.
- The index is maintained **inline inside each source write's transaction**, not by a background job — so a
  profile change is visible in search immediately, and a failed index write fails the source write.
- `Search:Backend` selects the implementation: unset or `sql` → `SqlNurseSearch`. Any other value **throws
  at startup** — Elasticsearch is deferred, and the config fails loudly rather than silently degrading.

## Enums

| Vocabulary | Values |
| --- | --- |
| `NurseGender` *(filter + result field)* | `male` `female` |
| `SearchSort` *(client)* | `rating` — the only sort implemented |

`nurse_gender` accepts `male`/`female`; **omit the param for "any"** — there is no `any` value on this
filter, unlike `requiredCaregiverGender` on a booking request.

## Open REQs

| REQ | Status | Effect |
| --- | --- | --- |
| REQ-040 | open | No `topReviewTag` on the index row. The C2 card renders without the tag chip |
| REQ-041 | open | No free-text `q` over nurse/variant/category names. Discovery is filter-only |
| REQ-042 | open | `NursePublicProfileDto` has no `nurseGender` — the *index row* has it, the profile does not, so the C3 screen cannot show the gender chip |
| REQ-066 | open, **narrower than filed** | `search/nurses` is **already anonymous**. What is missing is the rate limit — `SearchController` carries no `[EnableRateLimiting]`, so guest browse falls to the 100/min global per-IP limiter |
| REQ-067 | open, **narrower than filed** | `nurses/{id}/profile` is **already anonymous**. What is missing is the privacy review of the payload for unauthenticated callers |
