Files
baya-monorepo/dev/phases/backend/backend-phase-7.md
T
2026-06-28 21:59:59 +03:30

358 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.