5839b3508f
Add the discovery layer: the denormalized nurse_search_index read model (one row per bookable variant x covered service area), maintained inline inside each source write's transaction, plus the single public search query behind the INurseSearch seam. - Entity + EF config + migration (search schema): covering search index, filtered-unique (variant_id, city_id, district_id) pair with NULL district participating, nurse_id index, soft-delete. - ISearchIndexMaintainer (write seam) + SearchIndexMaintainer: reindex variant / nurse / fan-out / remove-area / full rebuild, staged in the owning source write's unit of work; wired into the b3/b4/b5/b6 handlers. - INurseSearch (read seam) + SqlNurseSearch (real MVP backend): reads only is_searchable=1, category/city/district(NULL-aware)/gender/price filters, rating sort, pagination. Elasticsearch deferred (config Search:Backend). - SearchNursesQuery (+ validator) and RebuildSearchIndexCommand; public SearchController (GET search/nurses) + admin AdminSearchController (POST admin_search/rebuild_index). - Tests: 9 DB-backed maintainer/search + 4 WebApplicationFactory; updated affected b3/b4/b5/b6 handler tests. Build clean, 167 tests green. - Docs: server CLAUDE.md project map, contract search.md, swagger refresh, handoff, report, mocks-registry rows, STATUS. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
6.3 KiB
6.3 KiB
Backend Phase 7 report — Search & matching (nurse search index)
What was built
nurse_search_indexread model —Domain/Entities/Search/NurseSearchIndex(tablesearch.NurseSearchIndices), one flat row per (bookable variant × covered service area): copiedvariant_id/nurse_id/service_category_id/price/price_unit, the coveredcity_id/district_id(NULL = whole city), the nurse'snurse_gender+average_rating/total_reviews/total_completed_bookings, the singleis_searchablegate,updated_at, soft-deletedeleted_at. EF config inPersistence/Configuration/SearchConfig/; one migrationNurseSearchIndex. 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 thenurse_service_areastrick); anurse_idsecondary 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 theVariantnavigation),ReindexNurseAsync(verification flip / suspend / accepting-toggle / rating recompute),FanOutServiceAreaAsyncRemoveServiceAreaRowsAsync(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 onlyis_searchable = 1rows, applies category/city/district(NULL-aware)/gender/price filters + rating sort + pagination,AsNoTracking+.Selectprojection;priceformatted to a digit string in memory.SearchNursesQuery(Features/Search/Queries/) + FluentValidation validator, delegating toINurseSearch;RebuildSearchIndexCommand(Features/Search/Commands/) →RebuildAsync+ audit log.- Controllers: public
SearchController(GET api/v1/search/nurses, snake_case query params, per-IP global rate limit) andAdminSearchController(POST api/v1/admin_search/rebuild_index, dynamic-permission +sensitiverate limit). - Wiring into source handlers (same-transaction maintenance): b5
CreateVariant/UpdateVariant/SetVariantActive; b4AddNurseServiceArea/RemoveNurseServiceArea; b3SetNurseAcceptingBookings; b6AdminReviewStep/AdminSuspendVerification/ScanExpiringCredentials/RunIdentityKyc/RunShahkarMatch/RunBankAccountVerification. - DI:
AddPersistenceServicesregistersISearchIndexMaintainer+ (config-selected)INurseSearch(Search:Backend, defaultsql).
What is now testable and exactly how (per phase §7)
Seed fixtures via SearchIndexTestHost (Foundation) or drive the live API. Verified against tests:
- 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.
- 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.
- Same-gender —
nurse_gender=female/malenarrows to that gender. - Price range —
min_price/max_pricefilter on the copied IRRprice; resultpriceis a digit string. - Rating sort — higher
average_ratingsorts first; deterministic paging. - Verification flip — suspend/un-verify → the nurse disappears from search in the same transaction; reinstating brings them back (row resurrected, not duplicated).
- Service-area fan-out/remove — adding an area adds its rows; removing it drops exactly those rows.
- Variant deactivate — the variant stops appearing (
is_searchable=0) without deleting its rows. - Rebuild convergence —
RebuildAsyncreproduces 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.jsonrefreshed. - 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, configSearch:Backend;ISearchIndexMaintainerchange-event shape). Steps inreports/mocks-registry.md(both rows). booking_requests.required_caregiver_gendercapture — owned by b8 (carry the chosen gender into the booking). b7 makesnurse_gendera 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/nursesresults into the booking flow; capturerequired_caregiver_gender. - Optional — a short-TTL
ICacheServicedecorator over hot (category, city, gender) result pages, invalidated on index writes for the affected city/category (shipped no-cache at MVP). - Perf —
RebuildAsyncdoes 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).