# 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).