Files
baya-monorepo/dev/contracts/domains/search.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

5.9 KiB
Raw Blame History

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; data shapes are below. Query parameters are snake_case (service_category_id, city_id, …).

Key semantics (read first)

  • Only is_searchable = 1 rows are ever returned. A row is searchable only when the nurse is is_verified AND not suspended AND is_accepting_bookings AND the variant is_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_variant matched 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).
  • 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. price in results is a digit string ("500000"); min_price/max_price filters are integers. No floats anywhere.
  • Rating sort only (MVP). Results are ordered by averageRating desc, tiebroken by totalReviews desc then nurseId/variantId so 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 copied price.
    • price_unit (enum, optional) — compare like-for-like listings (e.g. only per_day).
    • page (int, default 1), page_size (int, default 50, max 100).
  • Success 200 payload (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: 400service_category_id/city_id missing or ≤ 0, nurse_gender not male/female, min_price > max_price, invalid price_unit, or page_size > 100.
  • Notes: returns only is_searchable = 1 rows. districtId = null in a result row means the nurse covers the whole city. No entity is hydrated — the read is a projected, paginated, AsNoTracking index 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 200 payload (data = SearchIndexRebuildResult):
    { "nursesProcessed": 128, "rowsWritten": 342 }
    
  • Failure cases: 401 unauthenticated · 403 non-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 impl SqlNurseSearch (real, over nurse_search_index). Config key Search:Backend (default sql); a later ElasticNurseSearch is a config-selected drop-in.

Changelog

  • b7 — initial contract: public search/nurses, admin admin_search/rebuild_index.