Files
baya-monorepo/archive/build-chain/contracts/domains/search.md
T
2026-08-02 18:48:32 +03:30

6.6 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), pageSize (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 pageSize > 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.

Refinement phase 3 additions (REQ-012)

  • NurseSearchResultDto gains nurseName + avatarUrl (denormalized onto nurse_search_index, so no per-row join) and distanceKm (nullable — the covering index carries no coordinate, so it is null today).
  • GET api/v1/nurses/{id}/profile (public) — the aggregated discovery detail: { nurseId, nurseName, avatarUrl, bio, yearsExperience, averageRating, totalReviews, totalCompletedBookings, isVerified, inoMembership, attributeChips[], services: [{ variantId, displayName, priceIrr, priceUnit, sessionCount? }], latestReview?: { rating, body, authorMasked (null by design), createdAt } }. No encrypted credential number is ever exposed.