cleanup phases 6
This commit is contained in:
@@ -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).
|
||||
Reference in New Issue
Block a user