# Backend Phase 7 — Search & matching (nurse search index)

> **Mission:** make verified nurses discoverable. Build the **denormalized `nurse_search_index`** — one
> flat row per bookable variant **per covered area** (fan-out) — and the write-side **maintenance hooks**
> that keep it consistent on every change to a nurse's profile, variants, service areas, verification, or
> reviews. Then build the single family-facing **search query** (filter by category, city/district with
> NULL-district = whole-city, gender, price; sort by rating; paginate) behind a **search-service seam**
> so an Elasticsearch backend can drop in later without touching callers. A row is searchable **only**
> when the nurse is verified, not suspended, accepting bookings, and the variant is active — an
> unverified or paused nurse must **never** surface. This is the discovery layer the whole booking funnel
> stands on.
>
> **Track:** backend · **Depends on:** [b5](./backend-phase-5.md) (catalog & variants), [b6](./backend-phase-6.md) (verification → `is_verified`), [b4](./backend-phase-4.md) (geography & service areas), [b3](./backend-phase-3.md) (nurse profiles, gender, rating aggregates) · **Unlocks:** booking discovery (b8); frontend **f6-b7**
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.

---

## 1. Context — where this sits

This is **backend phase b7**, the discovery layer. Catalog (b5) gave nurses priced **variants** — the
atomic bookable unit; geography (b4) gave them **service areas** (which cities/districts they travel to);
verification (b6) gave them the guarded **`is_verified`** flag; profiles (b3) gave them **gender** and the
denormalized **rating aggregates**. All those facts live in normalized tables across four domains. A naive
"find me a verified female elder-care nurse in Tehran district 3 under X rials, best-rated first" query
joins `nurse_profiles → nurse_service_variants → nurse_service_areas` plus a rating sort across 4+ tables —
slow at modest scale. This phase **flattens all of it into one maintained-on-write read model** so search
is a single indexed, paginated table scan, and **introduces the `INurseSearch` seam** so MVP runs on SQL
today and Elasticsearch can replace the backend later by config alone. No Elasticsearch at MVP — the index
table *is* the search backend (and stays the projection/fallback even after Elastic lands).

**What already exists (do not rebuild) — built by prior phases:**
- **Catalog & bookable variants** — [b5](./backend-phase-5.md) built `service_categories`,
  `service_option_groups`/`service_option_values`, and the nurse-side **`nurse_service_variants`**
  (`nurse_id`, `service_category_id`, `price`, `price_unit` ∈ `per_hour`/`per_session`/`per_half_day`/
  `per_day`/`per_24h`, `session_count`, `is_active`, `display_name`) + `nurse_service_variant_options`.
  **The variant is the bookable unit — search projects *variants*, not nurses.** b5 already stubbed a
  write-side hook on variant create/edit/activate/deactivate (the catalog digest's `ISearchIndexWriter`
  note); **this phase owns the real implementation** behind that hook — do not re-create the variant CRUD.
- **Geography & service areas** — [b4](./backend-phase-4.md) built `provinces`/`cities`/`districts` (each
  with `sort_order`, `is_active`) and **`nurse_service_areas`** (`nurse_id`, `city_id`, `district_id` NULL,
  `UNIQUE(nurse_id, city_id, district_id)`; **`district_id = NULL` means the whole city**). The geo lookup
  queries (ListProvinces/ListCities/ListDistricts) and the nurse Add/Remove-ServiceArea commands already
  exist — **this phase hooks the index fan-out onto the service-area writes**, it does not rebuild the geo
  domain or the area editor.
- **Nurse profiles, gender & rating aggregates** — [b3](./backend-phase-3.md) built `nurse_profiles`
  (`user_id` UNIQUE, the guarded `is_verified` BIT, `is_accepting_bookings` BIT, the denormalized
  `average_rating`/`total_reviews`/`total_completed_bookings`) and `users.gender` (`male`/`female`). The
  **aggregate-recompute on review/booking transitions** is wired in b3/b14 — this phase **reads** those
  fields into the index, it does not own the recompute math.
- **Verification & the `is_verified` flip** — [b6](./backend-phase-6.md) built `nurse_verifications`
  (the sole source of verification truth; `status` ∈ `not_started`/`pending`/`in_review`/`approved`/
  `rejected`/`suspended`) and the **guarded flip** that sets `nurse_profiles.is_verified=1` only inside
  the confirm transaction (and reverses it on suspension). **This phase hooks index maintenance onto that
  flip** so flipping `is_verified` flips the rows' `is_searchable` — it does not touch the verification
  pipeline.
- The b0 foundation + b1 plumbing: REST surface, `BaseController`, `OperationResult<T>`, CQRS via
  **`martinothamar/Mediator`**, `IDateTimeProvider`, the typed cached `platform_configs` accessor, and the
  **`ICacheService`** seam (optional result/geo-lookup caching). Reuse all of these.

**What this phase introduces:** the `nurse_search_index` table + its EF config + migration, the
**index-maintenance handlers** (upsert/fan-out/remove + `is_searchable` recomputation + a full backfill/
rebuild job), the **`SearchNurses` query** behind the new **`INurseSearch` seam** (SQL impl
`SqlNurseSearch` now), and the public `GET /search/nurses` endpoint. The same-gender filter is first-class
here. Booking-side capture of `booking_requests.required_caregiver_gender` is owned by **b8 (DEFERRED**
here — see §3).

## 2. Required reading (do this first)

- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
  [`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md) —
  especially *Persistence* (AsNoTracking + `.Select` projection, pagination on every list, one
  `IEntityTypeConfiguration<T>` per entity) and *Performance/caching* (cache read-heavy data behind the
  cache seam).
- [`../../../product/business/04-search-and-matching.md`](../../../product/business/04-search-and-matching.md) —
  **the business rules**: search by category + city/district + price + rating; geography driven by
  nurse-declared service areas (city-level row = whole city); **the denormalized index exists only when the
  nurse is verified + not suspended + the variant active**; **same-gender matching as a first-class,
  near-hard filter** surfaced *before* booking; MVP (this) vs DEFERRED (map discovery, hard availability
  filter, algorithmic ranking).
- [`../../../product/data-model/03-services-and-pricing.md`](../../../product/data-model/03-services-and-pricing.md) —
  **the canonical `nurse_search_index` schema** (the field table: `variant_id`, `nurse_id`,
  `service_category_id`, copied `price`/`price_unit`, `city_id`/`district_id` one-row-per-area, `nurse_gender`,
  copied `average_rating`/`total_reviews`/`total_completed_bookings`, `is_searchable`, `updated_at`) and the
  **"maintained on writes to `nurse_profiles`, `nurse_service_variants`, `nurse_service_areas`, `reviews`"**
  + **"`is_searchable=1` only when the source nurse/variant are bookable"** invariants. Mirror these names
  exactly.
- **Code to mirror:** b5's `Features/Catalog/**` (the variant create/edit/activate/deactivate commands and
  the write-side hook stub you'll implement); b4's `nurse_service_areas` config + the Add/Remove-ServiceArea
  commands; b3's `nurse_profiles` config (gender + rating aggregate columns) and the aggregate-recompute
  path; b6's `is_verified` flip transaction. Mirror their `Features/<Area>/{Commands|Queries}/<Name>/`
  layout, `IEntityTypeConfiguration<T>`, and the `IUnitOfWork`/`CommitAsync` pattern.
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
  and [`money-and-types.md`](../../contracts/conventions/money-and-types.md) (IRR `BIGINT`, the envelope,
  pagination shape). `price` in the index and in search responses is **IRR `long`/`BIGINT`** — no floats.
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-5.md`, `…-6.md`,
  `…-4.md`, `…-3.md`, and `reports/mocks-registry.md` (the `INurseSearch`/`ICacheService` seam rows).

## 3. Scope — build this

Money (`price`) is IRR `long` / `BIGINT`. The search read model + its maintenance live under
`Baya.Application/Features/Search/{Commands|Queries}/<Name>/`; the entity in
`Baya.Domain/Entities/Search/`; one `IEntityTypeConfiguration<T>` in
`Persistence/Configuration/SearchConfig/`; the `INurseSearch` seam in `Application/Contracts/`, its SQL
implementation in Infrastructure; one EF migration for the table.

### 3.1 Entity + migration

**`nurse_search_index`** [CORE] — denormalized read model; **one row per (variant × covered area)** (fan-out).
- Fields (mirror [`product/data-model/03-services-and-pricing.md`](../../../product/data-model/03-services-and-pricing.md)):
  - `id` BIGINT PK.
  - `variant_id` BIGINT FK → `nurse_service_variants`.
  - `nurse_id` BIGINT FK → `nurse_profiles`.
  - `service_category_id` BIGINT FK → `service_categories` (copied from the variant — the primary search
    dimension).
  - `price` BIGINT, `price_unit` NVARCHAR (copied from the variant; closed `price_unit` set).
  - `city_id` BIGINT, `district_id` BIGINT **NULL** — the covered area this row represents.
    **`district_id = NULL` is a meaningful value: "whole city".** One row per area the nurse covers
    (fan-out); a nurse covering 3 areas with 2 active variants yields up to 6 rows.
  - `nurse_gender` NVARCHAR(10) — copied from `users.gender` via the nurse, for the same-gender filter.
  - `average_rating`, `total_reviews`, `total_completed_bookings` — copied from `nurse_profiles`.
  - `is_searchable` BIT — **true only when** nurse `is_verified=1` AND `nurse_verifications.status` not
    `suspended` AND `is_accepting_bookings=1` AND variant `is_active=1` (see §5). The single visibility gate.
  - `updated_at` DATETIME2 — stamped from `IDateTimeProvider` on every upsert.
- **Indexes (this is the whole point — get them right):**
  - A **covering composite index** for the hot search path:
    `(is_searchable, service_category_id, city_id, district_id) INCLUDE (price, nurse_gender, average_rating, nurse_id, variant_id)`
    — so the filtered, rating-sorted page is served from the index without key lookups.
  - A **filtered unique index** `UNIQUE(variant_id, city_id, district_id) WHERE deleted_at IS NULL`
    (district_id NULL participating) so a given variant×area appears **exactly once** — this is the upsert
    target and the anti-duplication guard.
  - A secondary index on `nurse_id` (so a nurse-scoped rebuild/remove is cheap).
- **No business writes here.** This table is a **read-only projection** — only the maintenance handlers in
  §3.2 write it, and only by re-deriving from source tables. Soft-delete + audit/`updated_at` wiring per
  conventions; the table is fully **re-derivable** from source by the backfill job (§3.2).

### 3.2 Index-maintenance handlers (the write side — owned by the source's code path)

The index is maintained **inline, inside the same transaction** as the source write that changes a
projected fact. The projection is written **only by the code path that owns the source row** — a variant
write reindexes that variant, a profile/verification write reindexes that nurse, a service-area write fans
out/removes that nurse's rows. These are small internal commands the existing b3/b4/b5/b6 handlers call (or
that the SaveChanges pipeline dispatches) — **not** public endpoints.

| Command | Trigger (source write) | What it does |
| --- | --- | --- |
| **`ReindexVariantCommand(variantId)`** | `nurse_service_variants` create / edit (price, category, options) / activate / deactivate (b5) | Recomputes the index rows for **this variant across all the nurse's service areas**: upserts one row per area (copying price/unit/category, the nurse's gender + rating + searchability), and sets `is_searchable` per the §5 predicate. On **deactivate** the variant's rows go `is_searchable=0` (kept, not deleted — soft-delete the rows on hard variant deletion only, which b5 forbids). |
| **`ReindexNurseCommand(nurseId)`** | `nurse_profiles` change: the **`is_verified` flip** (b6), **suspend/un-suspend**, **`is_accepting_bookings` toggle** (b3), and the **rating-aggregate recompute** on review/booking transitions (b3/b14) | Re-derives **every row for the nurse** = (each active variant) × (each service area), recomputing `is_searchable`, and refreshing the copied `nurse_gender`/`average_rating`/`total_reviews`/`total_completed_bookings`. Flipping `is_verified` from 1→0 (or suspend) sets **all** the nurse's rows `is_searchable=0` in one go; flipping 0→1 (with accepting + active variants) makes them searchable. |
| **`FanOutServiceAreaCommand(nurseId, cityId, districtId)`** | `nurse_service_areas` **add** (b4) | Inserts one index row **per active variant** for the newly-covered area (respecting the `UNIQUE(variant_id, city_id, district_id)` upsert guard), with `is_searchable` per §5. |
| **`RemoveServiceAreaRowsCommand(nurseId, cityId, districtId)`** | `nurse_service_areas` **remove** (b4) | Deletes (soft-deletes) the index rows for that nurse×area across all variants. Removing an area must drop exactly those rows — **don't collapse areas or you break geo filtering** (§5). |
| **`RebuildSearchIndexCommand`** [job] | manual admin trigger / first-launch / nightly reconciliation | **Idempotent full rebuild**: truncates+repopulates (or upserts+prunes) the entire index from `nurse_profiles` × `nurse_service_variants` × `nurse_service_areas`, applying §5. This is the convergence/reconciliation path — the index must be **re-derivable from source** at any time. Batched/paginated so it scales. Admin-only endpoint `POST api/v1/admin_search/rebuild_index` (rate-limited, admin policy). |

- **Transactionality:** the reindex step runs **inside the same `IUnitOfWork` transaction** as its source
  write (single `CommitAsync`) so the projection can never diverge from the source on a successful commit; a
  source write that rolls back rolls back its index change too. (The seam is shaped so a later Elastic
  feeder can instead consume these as outbox events — see §4 — but the **SQL path applies them inline
  today**.)
- **No `ISearchIndexWriter` controller surface.** These commands are internal; they are invoked from the
  owning domain's handlers, never exposed as their own REST routes (except the admin rebuild job).

### 3.3 The search query + seam (the read side)

**`INurseSearch`** (Application contract) — the search-service seam. SQL implementation `SqlNurseSearch`
(Infrastructure) reads **only** `nurse_search_index WHERE is_searchable = 1`. **All callers depend on the
interface**, never on raw SQL or an Elastic client, so the MVP→Elastic swap is config-only.

**`SearchNursesQuery`** [CORE] — the single family-facing discovery query, delegating to `INurseSearch`.
- Route: **`GET api/v1/search/nurses`** (public — discovery is pre-auth; **rate-limited** as an unauthenticated
  public endpoint).
- Filters (all optional except category + city per the product doc's "city required, district optional"):
  - `service_category_id` (required) — the primary dimension.
  - `city_id` (required), `district_id` (optional) — **geography matching:** a city search matches **both**
    the city-only rows (`district_id IS NULL`, "whole city") **and** any row for a district in that city; a
    district search matches that district's rows **plus** the whole-city rows (NULL district covers it). Get
    this exactly right (§5).
  - `nurse_gender` (optional, `male`/`female`) — the **first-class same-gender filter**.
  - `min_price` / `max_price` (optional, IRR `BIGINT`) — price range over the copied `price`.
  - (Optional, surfaced for UI) `price_unit` filter so "per_hour" and "per_day" listings can be compared
    like-for-like; not required.
- Sort: by `average_rating` **descending** (stable tiebreak on `total_reviews` desc then `nurse_id` so
  paging is deterministic). Rating sort is the only MVP sort.
- **Always paginated** (`page`/`page_size`, default/max per conventions) — `AsNoTracking()` + `.Select(...)`
  projection to a `NurseSearchResultDto` (nurse_id, variant_id, category, price + unit, nurse_gender,
  average_rating, total_reviews, total_completed_bookings, city/district) — never hydrate entities to map
  them.
- **Caching:** optionally cache hot (category, city, gender) result pages behind **`ICacheService`** (reuse
  the b0/b1 seam) with a short TTL, invalidated on index writes for the affected city/category — or ship
  no-cache at MVP and add the decorator later. Geo-lookup dropdowns (provinces/cities/districts) are already
  cacheable via b4; don't duplicate them here.
- **Controller:** `SearchController` (`sealed : BaseController`, inject `ISender`, snake_case
  `[controller]`/`[action]` routes, `base.OperationResult(...)`, `CancellationToken` threaded). Plus
  `AdminSearchController` for the rebuild job (admin policy).
- **Validators:** FluentValidation on `SearchNursesQuery` (category + city required; `min_price ≤ max_price`;
  `nurse_gender ∈ {male,female}` when present; `page_size` ≤ max).

### 3.4 DEFERRED (do not build; leave the seam/pointer)

- **Booking-side same-gender capture** — `booking_requests.required_caregiver_gender` (`male`/`female`/`any`)
  and the "surface the chosen gender *into* the booking flow before booking" guarantee are owned by
  **[b8](./backend-phase-8.md)**. This phase makes `nurse_gender` a **first-class search facet** (so families
  can narrow up front) and stops there. (DEFERRED → b8.)
- **Elasticsearch backend** (`ElasticNurseSearch` behind `INurseSearch`) and the **feeder/outbox daemon**
  that streams source changes into Elastic. DEFERRED — the SQL index is the MVP backend and remains the
  projection/fallback. Build the **seam**, not the Elastic impl. (See §4.)
- **Availability as a hard filter** — `nurse_availability_slots`/`nurse_availability_exceptions` are **soft
  guidance only**; never block a search result on availability ([`product/business/04-search-and-matching.md`](../../../product/business/04-search-and-matching.md) §(c)). DEFERRED.
- **Map-based discovery / geocoding** (`IGeocoder` for radius search), **algorithmic ranking beyond rating**,
  **"preferred nurse" continuity-of-carer** suggestions. DEFERRED.

## 4. Mocks & seams in this phase

| Seam | Owner | Behaviour | Registry |
| --- | --- | --- | --- |
| **`INurseSearch`** | **introduced here** | The search-service seam. **MVP impl `SqlNurseSearch` is the real backend, not a mock** — keep it production-grade: it reads `nurse_search_index WHERE is_searchable=1`, applies the category/city/district/gender/price filters, rating sort, and pagination. The DEFERRED `ElasticNurseSearch` is a config-selected drop-in later; callers depend only on `INurseSearch`. | **add a new row** (🟢 SQL is real; Elastic 🟡 deferred) |
| **`ISearchIndexWriter`** (events) | **introduced here** (shape only) | The index-maintenance seam. The SQL path applies the reindex/fan-out/remove **inline** today (§3.2). Shape it so the same change events can later be routed to an **outbox/queue** for the Elastic feeder instead of an inline upsert — record the swap path, but **do not** build the queue now. | **add a new row** (🟡 outbox deferred) |
| `ICacheService` | reuse from **b0/b1** | in-memory; optional decorator over hot search result pages + the typed config accessor. | reuse row |
| `IDateTimeProvider` | reuse from **b0** | stamps `updated_at` on every index upsert (deterministic in tests). | reuse row |

The Elastic implementation is a **DI-registered drop-in** behind `INurseSearch` (selection by config,
**never** an `if (mock)` branch in a handler). Append the `INurseSearch` + `ISearchIndexWriter` rows to
[`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
(seam, file, what's real/faked, config keys, **step-by-step how to make it real** — the Elastic client
package, the index mapping, the feeder daemon that consumes the writer's events via outbox/CDC, the
config switch that points `INurseSearch` at `ElasticNurseSearch`, and the fact that the SQL index stays
the fallback/reconciliation source).

## 5. Critical rules you must not get wrong

- **The visibility invariant is the safety rule.** A row is `is_searchable = 1` **only** when the nurse is
  `is_verified = 1` AND **not suspended** (`nurse_verifications.status != 'suspended'`) AND
  `is_accepting_bookings = 1` AND the variant `is_active = 1`. **An unverified, suspended, paused, or
  deactivated nurse/variant must never appear in results.** Get this wrong and you leak unvetted or
  unbookable nurses to families — the single highest-stakes bug in this phase. Recompute `is_searchable` on
  **every** relevant source write; never trust a stale value.
- **The bookable unit is the variant, not the nurse.** Search, results, and (later) booking operate on
  `nurse_service_variants`. The index is keyed per-variant-per-area; never collapse a nurse's variants into
  one row.
- **The index is a read-only projection — never let search mutate source.** All writes re-derive from
  `nurse_profiles` / `nurse_service_variants` / `nurse_service_areas` / `reviews`. The `RebuildSearchIndexCommand`
  must reconstruct the entire index from source with the same result as the incremental hooks — incremental
  maintenance and full rebuild must **converge**. If they can diverge, the maintenance logic is wrong.
- **Maintain inside the source's transaction.** The reindex runs in the **same unit of work** as the write
  that changed the projected fact (one `CommitAsync`), so a committed source change always carries its index
  change and a rolled-back one carries neither. The projection is written **only by the code path owning the
  source row** — don't reindex a variant from the profile handler, or vice-versa.
- **`district_id = NULL` means the whole city — a real coverage value, not missing data.** A **city search
  matches both city-only rows (NULL district) and every district row in that city**; a **district search
  matches that district's rows *and* the whole-city (NULL) rows.** NULL participates correctly in the
  `UNIQUE(variant_id, city_id, district_id)` upsert guard. Reimplementing geography as GPS radii is wrong —
  think named districts.
- **Fan-out cardinality is exact.** One index row per (variant × covered area). Adding a service area
  **inserts** one row per active variant; removing it **deletes** exactly those rows; deactivating a variant
  flips its rows to `is_searchable=0`. Don't collapse, dedupe-away, or orphan rows.
- **Same-gender matching is near-hard and first-class.** `nurse_gender` is an **exposed, up-front search
  filter** — for bodily-care, a gender mismatch is culturally unacceptable. Make same-gender easy to select
  and the facet prominent; never silently default or drop it. (Carrying the chosen gender *into* the booking
  request is b8.)
- **Money is IRR `BIGINT`, no floats.** The copied `price` and the `min_price`/`max_price` filters are
  `long`/`BIGINT`; price-unit display + `session_count` totals are not recomputed here. No float path,
  anywhere.
- **Rating-sort consistency.** The copied `average_rating`/`total_reviews`/`total_completed_bookings` must
  track the source recompute (every review status transition, booking completion/dispute reversal, nightly
  reconciliation). A hidden 1★ review must lower the rating **in the index**, not leave it inflated — which
  is why the rating-recompute path (b3/b14) calls `ReindexNurseCommand`.
- **Seam discipline.** Controllers/handlers depend on **`INurseSearch`**, never on raw SQL or an Elastic
  client directly, so the MVP→Elastic swap is config-only. The SQL impl is real and production-grade, not a
  throwaway mock.
- **Availability is soft.** Availability slots/exceptions never hard-filter search results at MVP; the nurse
  still accepts/rejects each request.

## 6. Definition of Done

The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `nurse_search_index` exists via one migration with its `IEntityTypeConfiguration<T>`, the covering
      search index, the **`UNIQUE(variant_id, city_id, district_id)` filtered** upsert guard (NULL district
      participating), the `nurse_id` secondary index, and soft-delete/audit/`updated_at` wiring.
- [ ] The §3.2 maintenance commands (`ReindexVariantCommand`, `ReindexNurseCommand`,
      `FanOutServiceAreaCommand`, `RemoveServiceAreaRowsCommand`, `RebuildSearchIndexCommand`) are
      implemented and **wired into the b3/b4/b5/b6 source handlers** so every relevant write maintains the
      index **in the same transaction**. `is_searchable` is recomputed correctly per §5 on each.
- [ ] `SearchNursesQuery` + `GET api/v1/search/nurses` implemented behind **`INurseSearch`** (`SqlNurseSearch`
      impl), reading **only** `is_searchable=1` rows, with correct NULL-district geography, the same-gender
      filter, price range, rating sort, projected + paginated reads, and the FluentValidation validator.
- [ ] **`INurseSearch`** (+ the `ISearchIndexWriter` event shape) introduced as Application interfaces with
      Infrastructure impls, **DI-registered via a `ServiceConfiguration/` extension** (config-selected; no
      `if (mock)` in handlers).
- [ ] Handler/unit tests (NSubstitute): the `is_searchable` predicate (verified+accepting+not-suspended+
      active true; each missing condition false), the geography NULL-district matching, the gender/price
      filters, the rating sort, the fan-out on area add/remove, and the **`is_verified` flip → index
      `is_searchable` update**; ≥1 `WebApplicationFactory` integration test for `/search/nurses` (happy path,
      validation 400). The convergence test: incremental maintenance == full rebuild for a seeded fixture.
      `dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green.
- [ ] The `Baya.Application/Features/Search/**` area + the `INurseSearch` seam are reflected in the **Project
      map** in `server/CLAUDE.md`.
- [ ] The contract `dev/contracts/domains/search.md` is written and the `swagger.json` snapshot republished.

## 7. How to test (what a human can verify after this phase)

Seed (or reuse from prior phases) a small fixture: a province/city with ≥2 districts; **Nurse A** = verified
(`is_verified=1`, `nurse_verifications.status=approved`), `is_accepting_bookings=1`, female, with one
**active** elder-care variant priced in IRR and a service area = (city, district 3); **Nurse B** = **not
verified** (or paused) with an otherwise identical variant + area; **Nurse C** = verified, accepting, male,
active variant, service area = **whole city** (`district_id=NULL`). Give the nurses different
`average_rating`s.

1. **Verified+accepting+active appears** — `GET api/v1/search/nurses?service_category_id=…&city_id=…` →
   **Nurse A** and **Nurse C** appear; **Nurse B does not** (unverified/paused never surfaces).
2. **Whole-city vs district geography** — search with `district_id=` district 3 → Nurse A (district 3) **and**
   Nurse C (whole-city NULL row) both match; search the same city with **no district** → both still match;
   search a *different* district in that city → only Nurse C (the whole-city row) matches.
3. **Same-gender filter** — add `nurse_gender=female` → only Nurse A; `nurse_gender=male` → only Nurse C.
4. **Price range** — set `min_price`/`max_price` straddling the variants → only the in-range variant(s) appear
   (IRR `BIGINT`, exact).
5. **Rating sort** — give Nurse A a higher `average_rating` than Nurse C → results come back A-before-C;
   paging is deterministic.
6. **Flip verification updates searchability** — flip **Nurse A** to suspended/`is_verified=0` (via the b6
   path) → re-run the search → **Nurse A disappears** (its rows' `is_searchable` went 0 in the same
   transaction). Flip back → it reappears.
7. **Service-area fan-out** — add a second service area to Nurse C → new index rows appear and Nurse C now
   matches that area too; remove the area → those rows (and only those) drop out.
8. **Variant deactivate** — deactivate Nurse A's variant → it stops appearing (rows `is_searchable=0`),
   without deleting the index rows.
9. **Rebuild convergence** — `POST api/v1/admin_search/rebuild_index` → the index is identical to its
   incrementally-maintained state (counts and `is_searchable` flags match); no duplicate (variant×area) rows.

## 8. Hand off & document (close the phase)

- **Docs to update:** the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) (add the
  `Features/Search/**` area, the `nurse_search_index` projection, and the `INurseSearch` seam + where it's
  registered). If you discover/confirm a rule the product docs don't capture (e.g. the exact city↔district
  NULL-matching semantics in the query, or the incremental-vs-rebuild convergence guarantee), record it in
  [`../../../product/business/04-search-and-matching.md`](../../../product/business/04-search-and-matching.md)
  or [`../../../product/data-model/03-services-and-pricing.md`](../../../product/data-model/03-services-and-pricing.md)
  — don't invent rules.
- **Contract to write:** **`dev/contracts/domains/search.md`** (per
  [`../../contracts/domains/_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) — the public
  `GET api/v1/search/nurses` endpoint (filters: `service_category_id`, `city_id`, optional `district_id`,
  optional `nurse_gender`, optional `min_price`/`max_price`, pagination; rating sort; the
  **NULL-district = whole-city** matching rule documented explicitly), the `NurseSearchResultDto` shape
  (IRR `BIGINT` `price` + `price_unit` enum, `nurse_gender`, rating fields), the admin
  `POST api/v1/admin_search/rebuild_index`, auth/rate-limit notes, and that **only `is_searchable=1` rows are
  ever returned**. Republish the `swagger.json` snapshot per
  [`../../contracts/openapi/README.md`](../../contracts/openapi/README.md). This is what **f6-b7** consumes.
- **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-7.md` (search
  is live, what **f6** can now build — search + filters (C1), results (C2), nurse profile (C3) — which
  endpoint/contract is live, that the backend is the SQL index with `INurseSearch` allowing a later Elastic
  swap, and that booking-side `required_caregiver_gender` capture lands in b8), append to `backend/STATUS.md`,
  write `dev/shared-working-context/reports/backend-phase-7-report.md` (what was built, **what is now
  testable and exactly how** per §7, what is deferred + how to make it real — Elastic + feeder, contracts
  produced, follow-ups: the booking-side gender capture, the optional result cache, the Elastic backend),
  and update `dev/shared-working-context/reports/mocks-registry.md` (the `INurseSearch` row → 🟢 SQL real,
  Elastic 🟡; the `ISearchIndexWriter` outbox row → 🟡).
- **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes — the
  `is_searchable` four-condition predicate, the **NULL-district = whole-city** matching (both directions),
  the (variant × area) fan-out cardinality + the `UNIQUE(variant_id, city_id, district_id)` upsert guard,
  the "maintain-in-the-source-transaction, projection owned by the source's code path" rule, the
  incremental↔rebuild convergence requirement, and the `INurseSearch` (SQL-now/Elastic-later) seam — with a
  one-line pointer in `MEMORY.md`.
