# 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`, 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` 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//{Commands|Queries}//` layout, `IEntityTypeConfiguration`, 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}//`; the entity in `Baya.Domain/Entities/Search/`; one `IEntityTypeConfiguration` 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`, 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`.