cleanup phases 6

This commit is contained in:
hamid
2026-08-02 18:48:32 +03:30
parent e2db97392a
commit 51e86a1e5f
239 changed files with 118 additions and 70 deletions
@@ -0,0 +1,83 @@
# 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).