Files
baya-monorepo/dev/shared-working-context/reports/backend-phase-7-report.md
T
hamid 5839b3508f backend phase 7: search & matching (nurse_search_index)
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>
2026-07-05 17:24:26 +03:30

6.3 KiB
Raw Blame History

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

What was built

  • nurse_search_index read modelDomain/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) + SearchIndexMaintainerPersistence/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) + SqlNurseSearchPersistence/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-gendernurse_gender=female/male narrows to that gender.
  4. Price rangemin_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 convergenceRebuildAsync 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).
  • PerfRebuildAsync 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).