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>
5.9 KiB
Contract — Nurse search & matching (backend phase b7)
The single public nurse-discovery endpoint (category + city/district geo, same-gender filter, price range, rating sort, paginated) plus the admin search-index rebuild. Reads a denormalized, maintained-on-write projection and returns only searchable (verified + accepting + not-suspended + active) nurses. Assumes
../conventions/api-conventions.md+../conventions/money-and-types.md. Machine schema:../openapi/swagger.v1.json(refreshed for b7).
Status: live as of backend-phase-b7 · Frontend consumer: frontend-phase-f6-b7
Routing note. Routes are action-style (
[controller]/[action], snake_cased). All responses use the standard{ succeeded, statusCode, data }envelope;datashapes are below. Query parameters are snake_case (service_category_id,city_id, …).
Key semantics (read first)
- Only
is_searchable = 1rows are ever returned. A row is searchable only when the nurse isis_verifiedAND not suspended ANDis_accepting_bookingsAND the variantis_active. An unverified, paused, suspended, or deactivated nurse/variant never appears — this is the phase's highest-stakes rule. - The result unit is the variant, not the nurse. Each hit is a bookable
nurse_service_variantmatched in a covered area; a nurse with multiple variants/areas can appear as multiple hits. district_id = null⇒ whole city, both directions:- A city-only search (no
district_id) matches every row in the city — both the whole-city (NULL) rows and every district row. - A district search matches that district's rows plus the whole-city (NULL) rows (a whole-city nurse covers every district).
- A city-only search (no
- Same-gender matching is a first-class facet.
nurse_gender(male/female) is an up-front filter; it is never silently defaulted or dropped. (Carrying the chosen gender into the booking request —booking_requests.required_caregiver_gender— lands in b8.) - Money is IRR
BIGINT.pricein results is a digit string ("500000");min_price/max_pricefilters are integers. No floats anywhere. - Rating sort only (MVP). Results are ordered by
averageRatingdesc, tiebroken bytotalReviewsdesc thennurseId/variantIdso paging is deterministic. - Availability is not a filter. Availability slots are soft guidance; they never hard-filter search (b7).
Enums used
nurse_gender:male|female.price_unit:per_hour|per_session|per_half_day|per_day|per_24h(copied from the variant).
Endpoints
GET api/v1/search/nurses
- Purpose: the single family-facing discovery query over the maintained search index.
- Auth: none (public, pre-auth discovery) · Rate-limited: yes (per-IP global limiter) · Idempotency key: no
- Query params:
service_category_id(long, required) — the primary search dimension.city_id(long, required).district_id(long, optional) — omit for a whole-city search; see geography rule above.nurse_gender(male|female, optional) — the same-gender facet.min_price/max_price(long IRR, optional) — inclusive range over the copiedprice.price_unit(enum, optional) — compare like-for-like listings (e.g. onlyper_day).page(int, default 1),page_size(int, default 50, max 100).
- Success
200payload (data=PagedResultOfNurseSearchResultDto):{ "items": [ { "variantId": 12, "nurseId": 5, "serviceCategoryId": 1, "price": "8000000", "priceUnit": "per_24h", "nurseGender": "female", "averageRating": 4.8, "totalReviews": 9, "totalCompletedBookings": 12, "cityId": 101, "districtId": 1003 } ], "total": 1, "page": 1, "pageSize": 50 } - Failure cases:
400—service_category_id/city_idmissing or ≤ 0,nurse_gendernotmale/female,min_price > max_price, invalidprice_unit, orpage_size > 100. - Notes: returns only
is_searchable = 1rows.districtId = nullin a result row means the nurse covers the whole city. No entity is hydrated — the read is a projected, paginated,AsNoTrackingindex scan.
POST api/v1/admin_search/rebuild_index
- Purpose: idempotent full rebuild of the search index from source — the convergence/reconciliation path (first-launch / nightly / after a bulk data fix).
- Auth: admin (dynamic-permission policy) · Rate-limited: yes (
sensitive) · Idempotency key: no - Request body: none.
- Success
200payload (data=SearchIndexRebuildResult):{ "nursesProcessed": 128, "rowsWritten": 342 } - Failure cases:
401unauthenticated ·403non-admin. - Notes: truncates and repopulates the whole index in nurse-batches; the rebuilt index's live/searchable rows must match the incrementally-maintained state (no duplicate variant×area rows). Writes an audit-log row.
Shared shapes
NurseSearchResultDto:variantId(long),nurseId(long),serviceCategoryId(long),price(string, IRR digits),priceUnit(enum),nurseGender(male/female),averageRating(decimal),totalReviews(int),totalCompletedBookings(int),cityId(long),districtId(long?, null = whole city).SearchIndexRebuildResult:nursesProcessed(int),rowsWritten(int).
Backend seam (not a wire shape)
INurseSearch— the search-service seam. MVP implSqlNurseSearch(real, overnurse_search_index). Config keySearch:Backend(defaultsql); a laterElasticNurseSearchis a config-selected drop-in.
Changelog
- b7 — initial contract: public
search/nurses, adminadmin_search/rebuild_index.