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>
This commit is contained in:
@@ -12,6 +12,31 @@ One block per completed backend phase. Newest at the top. Backend lane writes he
|
||||
- **Notes for frontend:** <anything load-bearing>
|
||||
-->
|
||||
|
||||
## backend-phase-7 — Search & matching (nurse search index) — 2026-07-05
|
||||
- **Shipped:** the discovery layer via one additive migration — new **`search`** schema, **1 table**
|
||||
`NurseSearchIndices` (the denormalized `nurse_search_index`): **one flat row per (bookable variant ×
|
||||
covered area)** with copied category/price/unit, `city_id`/`district_id` (NULL = whole city), `nurse_gender`
|
||||
+ rating aggregates, and the single **`is_searchable`** gate. It is a **read-only projection**, maintained
|
||||
**inline in each source write's own transaction** by **`ISearchIndexMaintainer`** (`SearchIndexMaintainer`)
|
||||
wired into the b3/b4/b5/b6 handlers (`ReindexVariant`/`ReindexNurse`/`FanOutServiceArea`/
|
||||
`RemoveServiceAreaRows` + `Rebuild`). Read side is the **`INurseSearch`** seam — real MVP impl
|
||||
`SqlNurseSearch` (reads only `is_searchable=1`; category/city/district(NULL-aware)/gender/price filters +
|
||||
rating sort + pagination). **2 controllers:** public `SearchController` (`GET search/nurses`) + admin
|
||||
`AdminSearchController` (`POST admin_search/rebuild_index`, idempotent convergence rebuild). Covering search
|
||||
index + filtered-unique `(variant_id, city_id, district_id)` pair (NULL participating) + `nurse_id` index.
|
||||
- **Contracts:** dev/contracts/domains/search.md + openapi snapshot refreshed (yes — `search/nurses` +
|
||||
`admin_search/rebuild_index` + DTOs).
|
||||
- **Mocked:** `INurseSearch` → 🟢 **SQL is real** (Elastic backend 🟡 deferred, config `Search:Backend`);
|
||||
`ISearchIndexMaintainer` inline path real, outbox/feeder 🟡 deferred (see reports/mocks-registry.md).
|
||||
- **Gate:** build clean (0 new code warnings) / tests green (167 pass: +9 DB-backed search + 4 API integration;
|
||||
affected b3/b4/b5/b6 handler tests updated for the new dependency).
|
||||
- **Handoff:** backend/handoff/after-backend-phase-7.md
|
||||
- **Notes for frontend:** f6-b7 = `GET api/v1/search/nurses` (public; snake_case params
|
||||
`service_category_id`/`city_id` required, optional `district_id`/`nurse_gender`/`min_price`/`max_price`/
|
||||
`price_unit`; `page`/`page_size` default 1/50 max 100). Returns **only searchable** nurses;
|
||||
`districtId=null` result = whole city; `price` is an IRR **digit string**; sort is rating-desc only.
|
||||
`required_caregiver_gender` capture into booking is **b8**.
|
||||
|
||||
## backend-phase-6 — Nurse verification & credentials (mocked vendors) — 2026-07-02
|
||||
- **Shipped:** the trust engine via one additive migration — new **`verif`** schema, **5 tables**:
|
||||
`NurseVerifications` (`status` = the **single source of verification truth**), `VerificationStepTypes`
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Handoff — after backend-phase-7 (Search & matching)
|
||||
|
||||
**Search is live.** Verified nurses are now discoverable through one public endpoint backed by a
|
||||
denormalized, maintained-on-write index. This unblocks **frontend f6-b7**: search + filters (C1), results
|
||||
list (C2), and the nurse profile (C3) can be built against a real API.
|
||||
|
||||
## What the frontend can now build (f6-b7)
|
||||
|
||||
- **Search + filters (C1)** → `GET api/v1/search/nurses` (public, no auth). Query params (snake_case):
|
||||
`service_category_id` (**required**), `city_id` (**required**), `district_id` (optional),
|
||||
`nurse_gender` (`male`/`female`, optional), `min_price`/`max_price` (IRR long, optional),
|
||||
`price_unit` (optional), `page`/`page_size` (default 1 / 50, max 100).
|
||||
- **Results list (C2)** → the `data` is a `PagedResult<NurseSearchResultDto>` (`items`, `total`, `page`,
|
||||
`pageSize`). Each item: `variantId`, `nurseId`, `serviceCategoryId`, `price` (IRR **digit string**),
|
||||
`priceUnit`, `nurseGender`, `averageRating`, `totalReviews`, `totalCompletedBookings`, `cityId`,
|
||||
`districtId` (null = whole city).
|
||||
- **Nurse profile (C3)** → reuse the b6 public trust badge (`GET api/v1/nurses/{id}/trust_badge`) and the b5
|
||||
public variant read (`GET api/v1/nurse_variants/get/{id}`) already live; b7 adds no new profile route.
|
||||
|
||||
Categories/cities/districts for the filter dropdowns come from the **b4** geo lookups (`geo/*`) and **b5**
|
||||
catalog (`catalog/*`) — unchanged.
|
||||
|
||||
## Rules the UI must respect
|
||||
|
||||
- **Only searchable nurses come back.** The backend returns a nurse **only** when verified + not suspended +
|
||||
accepting + variant active. No client-side re-check needed; an empty page is a valid result.
|
||||
- **`districtId = null` = whole city.** A city-only search returns both whole-city and district rows; a
|
||||
district search returns that district's rows **plus** whole-city rows. Show whole-city hits as covering the
|
||||
district the user searched.
|
||||
- **Same-gender filter is first-class.** Surface `nurse_gender` prominently; never default it silently.
|
||||
(Carrying the chosen gender into the booking request — `required_caregiver_gender` — is **b8**, not here.)
|
||||
- **`price` is an IRR digit string** — render with a formatter; never parse to a float. Combine with
|
||||
`priceUnit` (+ `sessionCount` from the variant, when booking) for the engagement total.
|
||||
- **Sort is rating-desc only** (MVP). No client sort options beyond what the API returns.
|
||||
|
||||
## Contracts
|
||||
|
||||
- New: [`dev/contracts/domains/search.md`](../../contracts/domains/search.md).
|
||||
- `swagger.v1.json` refreshed (adds `search/nurses` + `admin_search/rebuild_index` + the DTOs).
|
||||
|
||||
## Backend notes (not frontend-facing)
|
||||
|
||||
- The index is a **read-only projection** maintained inline inside each source write's transaction
|
||||
(`ISearchIndexMaintainer`, wired into the b3/b4/b5/b6 handlers). The read seam is **`INurseSearch`**
|
||||
(SQL now; Elasticsearch is a config-selected drop-in later, `Search:Backend`).
|
||||
- Admin `POST api/v1/admin_search/rebuild_index` (dynamic-permission) does an idempotent full rebuild — the
|
||||
reconciliation path; incremental maintenance and rebuild converge.
|
||||
- **Deferred to b8:** `booking_requests.required_caregiver_gender` capture (carry the chosen gender into the
|
||||
booking). **Deferred:** Elasticsearch backend + feeder, availability hard-filter, map/radius discovery,
|
||||
ranking beyond rating.
|
||||
Reference in New Issue
Block a user