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

## What was built

- **`nurse_search_index` read model** — `Domain/Entities/Search/NurseSearchIndex` (table `search.NurseSearchIndices`),
  one flat row per **(bookable variant × covered service area)**: copied `variant_id`/`nurse_id`/
  `service_category_id`/`price`/`price_unit`, the covered `city_id`/`district_id` (NULL = whole city), the
  nurse's `nurse_gender` + `average_rating`/`total_reviews`/`total_completed_bookings`, the single
  `is_searchable` gate, `updated_at`, soft-delete `deleted_at`. EF config in
  `Persistence/Configuration/SearchConfig/`; one migration `NurseSearchIndex`. Indexes: a **covering** search
  index `(is_searchable, service_category_id, city_id, district_id) INCLUDE (price, nurse_gender,
  average_rating, total_reviews, nurse_id, variant_id)`; the **filtered-unique pair** on `(variant_id, city_id,
  district_id) WHERE deleted_at IS NULL` (NULL-district participating, via the `nurse_service_areas` trick); a
  `nurse_id` secondary index; soft-delete query filter.
- **`ISearchIndexMaintainer` (write seam) + `SearchIndexMaintainer`** — `Persistence/Services/Search/`. Keeps
  the index consistent **inline, in the source write's own unit of work**. Methods: `ReindexVariantAsync`
  (variant create/edit/toggle — inserts a new variant's rows in the same graph via the `Variant` navigation),
  `ReindexNurseAsync` (verification flip / suspend / accepting-toggle / rating recompute), `FanOutServiceAreaAsync`
  + `RemoveServiceAreaRowsAsync` (area add/remove), `RebuildAsync` (idempotent full rebuild). Resurrects a
  soft-deleted (variant × area) row on re-upsert so each pair has exactly one live row.
- **`INurseSearch` (read seam) + `SqlNurseSearch`** — `Persistence/Services/Search/`. Reads **only
  `is_searchable = 1`** rows, applies category/city/district(NULL-aware)/gender/price filters + rating sort +
  pagination, `AsNoTracking` + `.Select` projection; `price` formatted to a digit string in memory.
- **`SearchNursesQuery`** (`Features/Search/Queries/`) + FluentValidation validator, delegating to `INurseSearch`;
  **`RebuildSearchIndexCommand`** (`Features/Search/Commands/`) → `RebuildAsync` + audit log.
- **Controllers:** public `SearchController` (`GET api/v1/search/nurses`, snake_case query params, per-IP
  global rate limit) and `AdminSearchController` (`POST api/v1/admin_search/rebuild_index`, dynamic-permission +
  `sensitive` rate limit).
- **Wiring into source handlers** (same-transaction maintenance): b5 `CreateVariant`/`UpdateVariant`/
  `SetVariantActive`; b4 `AddNurseServiceArea`/`RemoveNurseServiceArea`; b3 `SetNurseAcceptingBookings`; b6
  `AdminReviewStep`/`AdminSuspendVerification`/`ScanExpiringCredentials`/`RunIdentityKyc`/`RunShahkarMatch`/
  `RunBankAccountVerification`.
- **DI:** `AddPersistenceServices` registers `ISearchIndexMaintainer` + (config-selected) `INurseSearch`
  (`Search:Backend`, default `sql`).

## What is now testable and exactly how (per phase §7)

Seed fixtures via `SearchIndexTestHost` (Foundation) or drive the live API. Verified against tests:
1. **Predicate** — a verified+accepting+not-suspended+active nurse is searchable; each missing condition
   (unverified / not accepting / suspended / inactive variant) makes it not searchable, but the row is kept.
2. **Geography** — district-3 search returns the district-3 nurse **and** the whole-city (NULL) nurse; a
   different district returns only the whole-city nurse; a city-only search returns both.
3. **Same-gender** — `nurse_gender=female`/`male` narrows to that gender.
4. **Price range** — `min_price`/`max_price` filter on the copied IRR `price`; result `price` is a digit string.
5. **Rating sort** — higher `average_rating` sorts first; deterministic paging.
6. **Verification flip** — suspend/un-verify → the nurse disappears from search in the same transaction;
   reinstating brings them back (row resurrected, not duplicated).
7. **Service-area fan-out/remove** — adding an area adds its rows; removing it drops exactly those rows.
8. **Variant deactivate** — the variant stops appearing (`is_searchable=0`) without deleting its rows.
9. **Rebuild convergence** — `RebuildAsync` reproduces the incrementally-maintained live/searchable row set,
   no duplicate (variant × area) rows.

**Tests:** `Baya.Test.Foundation/Search/SearchIndexTests` (9 DB-backed over real EF/SQLite) +
`Baya.Test.Api/SearchApiTests` (4 WebApplicationFactory: public paged happy path, 400 missing category, 400
invalid gender, 401 rebuild-unauth). Affected b3/b4/b5/b6 handler unit tests updated for the new dependency.
**Gate:** `dotnet build Baya.sln` 0 new warnings; `dotnet test Baya.sln` green (167 pass).

Manual: `GET /api/v1/search/nurses?service_category_id=…&city_id=…` (public) returns the paged envelope;
`POST /api/v1/admin_search/rebuild_index` (admin) returns `{ nursesProcessed, rowsWritten }`.

## Contracts produced / consumed

- **Produced:** `dev/contracts/domains/search.md`; `dev/contracts/openapi/swagger.v1.json` refreshed.
- **Consumed:** b3 (profiles/gender/aggregates), b4 (service areas / geo), b5 (variants), b6 (verification status).

## What is mocked / deferred + how to make it real

- **Elasticsearch backend (`ElasticNurseSearch`) + outbox feeder** — DEFERRED. The SQL index is the real MVP
  backend and stays the projection/fallback. Seam ready (`INurseSearch`, config `Search:Backend`;
  `ISearchIndexMaintainer` change-event shape). Steps in `reports/mocks-registry.md` (both rows).
- **`booking_requests.required_caregiver_gender` capture** — owned by **b8** (carry the chosen gender into the
  booking). b7 makes `nurse_gender` a first-class search facet and stops there.
- **Availability hard-filter, map/radius discovery, ranking beyond rating, preferred-nurse continuity** —
  DEFERRED per the product doc.

## Follow-ups for later phases

- **b8** — consume `search/nurses` results into the booking flow; capture `required_caregiver_gender`.
- **Optional** — a short-TTL `ICacheService` decorator over hot (category, city, gender) result pages,
  invalidated on index writes for the affected city/category (shipped no-cache at MVP).
- **Perf** — `RebuildAsync` does per-nurse reads (N+1); fine for the batched admin/nightly job, worth a
  set-based rewrite if the nurse count grows large.
- **Elastic** — build the outbox + feeder when search scale demands it (both registry rows).
