358 lines
31 KiB
Markdown
358 lines
31 KiB
Markdown
# Backend Phase 7 — Search & matching (nurse search index)
|
||
|
||
> **Mission:** make verified nurses discoverable. Build the **denormalized `nurse_search_index`** — one
|
||
> flat row per bookable variant **per covered area** (fan-out) — and the write-side **maintenance hooks**
|
||
> that keep it consistent on every change to a nurse's profile, variants, service areas, verification, or
|
||
> reviews. Then build the single family-facing **search query** (filter by category, city/district with
|
||
> NULL-district = whole-city, gender, price; sort by rating; paginate) behind a **search-service seam**
|
||
> so an Elasticsearch backend can drop in later without touching callers. A row is searchable **only**
|
||
> when the nurse is verified, not suspended, accepting bookings, and the variant is active — an
|
||
> unverified or paused nurse must **never** surface. This is the discovery layer the whole booking funnel
|
||
> stands on.
|
||
>
|
||
> **Track:** backend · **Depends on:** [b5](./backend-phase-5.md) (catalog & variants), [b6](./backend-phase-6.md) (verification → `is_verified`), [b4](./backend-phase-4.md) (geography & service areas), [b3](./backend-phase-3.md) (nurse profiles, gender, rating aggregates) · **Unlocks:** booking discovery (b8); frontend **f6-b7**
|
||
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
|
||
|
||
---
|
||
|
||
## 1. Context — where this sits
|
||
|
||
This is **backend phase b7**, the discovery layer. Catalog (b5) gave nurses priced **variants** — the
|
||
atomic bookable unit; geography (b4) gave them **service areas** (which cities/districts they travel to);
|
||
verification (b6) gave them the guarded **`is_verified`** flag; profiles (b3) gave them **gender** and the
|
||
denormalized **rating aggregates**. All those facts live in normalized tables across four domains. A naive
|
||
"find me a verified female elder-care nurse in Tehran district 3 under X rials, best-rated first" query
|
||
joins `nurse_profiles → nurse_service_variants → nurse_service_areas` plus a rating sort across 4+ tables —
|
||
slow at modest scale. This phase **flattens all of it into one maintained-on-write read model** so search
|
||
is a single indexed, paginated table scan, and **introduces the `INurseSearch` seam** so MVP runs on SQL
|
||
today and Elasticsearch can replace the backend later by config alone. No Elasticsearch at MVP — the index
|
||
table *is* the search backend (and stays the projection/fallback even after Elastic lands).
|
||
|
||
**What already exists (do not rebuild) — built by prior phases:**
|
||
- **Catalog & bookable variants** — [b5](./backend-phase-5.md) built `service_categories`,
|
||
`service_option_groups`/`service_option_values`, and the nurse-side **`nurse_service_variants`**
|
||
(`nurse_id`, `service_category_id`, `price`, `price_unit` ∈ `per_hour`/`per_session`/`per_half_day`/
|
||
`per_day`/`per_24h`, `session_count`, `is_active`, `display_name`) + `nurse_service_variant_options`.
|
||
**The variant is the bookable unit — search projects *variants*, not nurses.** b5 already stubbed a
|
||
write-side hook on variant create/edit/activate/deactivate (the catalog digest's `ISearchIndexWriter`
|
||
note); **this phase owns the real implementation** behind that hook — do not re-create the variant CRUD.
|
||
- **Geography & service areas** — [b4](./backend-phase-4.md) built `provinces`/`cities`/`districts` (each
|
||
with `sort_order`, `is_active`) and **`nurse_service_areas`** (`nurse_id`, `city_id`, `district_id` NULL,
|
||
`UNIQUE(nurse_id, city_id, district_id)`; **`district_id = NULL` means the whole city**). The geo lookup
|
||
queries (ListProvinces/ListCities/ListDistricts) and the nurse Add/Remove-ServiceArea commands already
|
||
exist — **this phase hooks the index fan-out onto the service-area writes**, it does not rebuild the geo
|
||
domain or the area editor.
|
||
- **Nurse profiles, gender & rating aggregates** — [b3](./backend-phase-3.md) built `nurse_profiles`
|
||
(`user_id` UNIQUE, the guarded `is_verified` BIT, `is_accepting_bookings` BIT, the denormalized
|
||
`average_rating`/`total_reviews`/`total_completed_bookings`) and `users.gender` (`male`/`female`). The
|
||
**aggregate-recompute on review/booking transitions** is wired in b3/b14 — this phase **reads** those
|
||
fields into the index, it does not own the recompute math.
|
||
- **Verification & the `is_verified` flip** — [b6](./backend-phase-6.md) built `nurse_verifications`
|
||
(the sole source of verification truth; `status` ∈ `not_started`/`pending`/`in_review`/`approved`/
|
||
`rejected`/`suspended`) and the **guarded flip** that sets `nurse_profiles.is_verified=1` only inside
|
||
the confirm transaction (and reverses it on suspension). **This phase hooks index maintenance onto that
|
||
flip** so flipping `is_verified` flips the rows' `is_searchable` — it does not touch the verification
|
||
pipeline.
|
||
- The b0 foundation + b1 plumbing: REST surface, `BaseController`, `OperationResult<T>`, CQRS via
|
||
**`martinothamar/Mediator`**, `IDateTimeProvider`, the typed cached `platform_configs` accessor, and the
|
||
**`ICacheService`** seam (optional result/geo-lookup caching). Reuse all of these.
|
||
|
||
**What this phase introduces:** the `nurse_search_index` table + its EF config + migration, the
|
||
**index-maintenance handlers** (upsert/fan-out/remove + `is_searchable` recomputation + a full backfill/
|
||
rebuild job), the **`SearchNurses` query** behind the new **`INurseSearch` seam** (SQL impl
|
||
`SqlNurseSearch` now), and the public `GET /search/nurses` endpoint. The same-gender filter is first-class
|
||
here. Booking-side capture of `booking_requests.required_caregiver_gender` is owned by **b8 (DEFERRED**
|
||
here — see §3).
|
||
|
||
## 2. Required reading (do this first)
|
||
|
||
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
|
||
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md) —
|
||
especially *Persistence* (AsNoTracking + `.Select` projection, pagination on every list, one
|
||
`IEntityTypeConfiguration<T>` per entity) and *Performance/caching* (cache read-heavy data behind the
|
||
cache seam).
|
||
- [`../../../product/business/04-search-and-matching.md`](../../../product/business/04-search-and-matching.md) —
|
||
**the business rules**: search by category + city/district + price + rating; geography driven by
|
||
nurse-declared service areas (city-level row = whole city); **the denormalized index exists only when the
|
||
nurse is verified + not suspended + the variant active**; **same-gender matching as a first-class,
|
||
near-hard filter** surfaced *before* booking; MVP (this) vs DEFERRED (map discovery, hard availability
|
||
filter, algorithmic ranking).
|
||
- [`../../../product/data-model/03-services-and-pricing.md`](../../../product/data-model/03-services-and-pricing.md) —
|
||
**the canonical `nurse_search_index` schema** (the field table: `variant_id`, `nurse_id`,
|
||
`service_category_id`, copied `price`/`price_unit`, `city_id`/`district_id` one-row-per-area, `nurse_gender`,
|
||
copied `average_rating`/`total_reviews`/`total_completed_bookings`, `is_searchable`, `updated_at`) and the
|
||
**"maintained on writes to `nurse_profiles`, `nurse_service_variants`, `nurse_service_areas`, `reviews`"**
|
||
+ **"`is_searchable=1` only when the source nurse/variant are bookable"** invariants. Mirror these names
|
||
exactly.
|
||
- **Code to mirror:** b5's `Features/Catalog/**` (the variant create/edit/activate/deactivate commands and
|
||
the write-side hook stub you'll implement); b4's `nurse_service_areas` config + the Add/Remove-ServiceArea
|
||
commands; b3's `nurse_profiles` config (gender + rating aggregate columns) and the aggregate-recompute
|
||
path; b6's `is_verified` flip transaction. Mirror their `Features/<Area>/{Commands|Queries}/<Name>/`
|
||
layout, `IEntityTypeConfiguration<T>`, and the `IUnitOfWork`/`CommitAsync` pattern.
|
||
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
|
||
and [`money-and-types.md`](../../contracts/conventions/money-and-types.md) (IRR `BIGINT`, the envelope,
|
||
pagination shape). `price` in the index and in search responses is **IRR `long`/`BIGINT`** — no floats.
|
||
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-5.md`, `…-6.md`,
|
||
`…-4.md`, `…-3.md`, and `reports/mocks-registry.md` (the `INurseSearch`/`ICacheService` seam rows).
|
||
|
||
## 3. Scope — build this
|
||
|
||
Money (`price`) is IRR `long` / `BIGINT`. The search read model + its maintenance live under
|
||
`Baya.Application/Features/Search/{Commands|Queries}/<Name>/`; the entity in
|
||
`Baya.Domain/Entities/Search/`; one `IEntityTypeConfiguration<T>` in
|
||
`Persistence/Configuration/SearchConfig/`; the `INurseSearch` seam in `Application/Contracts/`, its SQL
|
||
implementation in Infrastructure; one EF migration for the table.
|
||
|
||
### 3.1 Entity + migration
|
||
|
||
**`nurse_search_index`** [CORE] — denormalized read model; **one row per (variant × covered area)** (fan-out).
|
||
- Fields (mirror [`product/data-model/03-services-and-pricing.md`](../../../product/data-model/03-services-and-pricing.md)):
|
||
- `id` BIGINT PK.
|
||
- `variant_id` BIGINT FK → `nurse_service_variants`.
|
||
- `nurse_id` BIGINT FK → `nurse_profiles`.
|
||
- `service_category_id` BIGINT FK → `service_categories` (copied from the variant — the primary search
|
||
dimension).
|
||
- `price` BIGINT, `price_unit` NVARCHAR (copied from the variant; closed `price_unit` set).
|
||
- `city_id` BIGINT, `district_id` BIGINT **NULL** — the covered area this row represents.
|
||
**`district_id = NULL` is a meaningful value: "whole city".** One row per area the nurse covers
|
||
(fan-out); a nurse covering 3 areas with 2 active variants yields up to 6 rows.
|
||
- `nurse_gender` NVARCHAR(10) — copied from `users.gender` via the nurse, for the same-gender filter.
|
||
- `average_rating`, `total_reviews`, `total_completed_bookings` — copied from `nurse_profiles`.
|
||
- `is_searchable` BIT — **true only when** nurse `is_verified=1` AND `nurse_verifications.status` not
|
||
`suspended` AND `is_accepting_bookings=1` AND variant `is_active=1` (see §5). The single visibility gate.
|
||
- `updated_at` DATETIME2 — stamped from `IDateTimeProvider` on every upsert.
|
||
- **Indexes (this is the whole point — get them right):**
|
||
- A **covering composite index** for the hot search path:
|
||
`(is_searchable, service_category_id, city_id, district_id) INCLUDE (price, nurse_gender, average_rating, nurse_id, variant_id)`
|
||
— so the filtered, rating-sorted page is served from the index without key lookups.
|
||
- A **filtered unique index** `UNIQUE(variant_id, city_id, district_id) WHERE deleted_at IS NULL`
|
||
(district_id NULL participating) so a given variant×area appears **exactly once** — this is the upsert
|
||
target and the anti-duplication guard.
|
||
- A secondary index on `nurse_id` (so a nurse-scoped rebuild/remove is cheap).
|
||
- **No business writes here.** This table is a **read-only projection** — only the maintenance handlers in
|
||
§3.2 write it, and only by re-deriving from source tables. Soft-delete + audit/`updated_at` wiring per
|
||
conventions; the table is fully **re-derivable** from source by the backfill job (§3.2).
|
||
|
||
### 3.2 Index-maintenance handlers (the write side — owned by the source's code path)
|
||
|
||
The index is maintained **inline, inside the same transaction** as the source write that changes a
|
||
projected fact. The projection is written **only by the code path that owns the source row** — a variant
|
||
write reindexes that variant, a profile/verification write reindexes that nurse, a service-area write fans
|
||
out/removes that nurse's rows. These are small internal commands the existing b3/b4/b5/b6 handlers call (or
|
||
that the SaveChanges pipeline dispatches) — **not** public endpoints.
|
||
|
||
| Command | Trigger (source write) | What it does |
|
||
| --- | --- | --- |
|
||
| **`ReindexVariantCommand(variantId)`** | `nurse_service_variants` create / edit (price, category, options) / activate / deactivate (b5) | Recomputes the index rows for **this variant across all the nurse's service areas**: upserts one row per area (copying price/unit/category, the nurse's gender + rating + searchability), and sets `is_searchable` per the §5 predicate. On **deactivate** the variant's rows go `is_searchable=0` (kept, not deleted — soft-delete the rows on hard variant deletion only, which b5 forbids). |
|
||
| **`ReindexNurseCommand(nurseId)`** | `nurse_profiles` change: the **`is_verified` flip** (b6), **suspend/un-suspend**, **`is_accepting_bookings` toggle** (b3), and the **rating-aggregate recompute** on review/booking transitions (b3/b14) | Re-derives **every row for the nurse** = (each active variant) × (each service area), recomputing `is_searchable`, and refreshing the copied `nurse_gender`/`average_rating`/`total_reviews`/`total_completed_bookings`. Flipping `is_verified` from 1→0 (or suspend) sets **all** the nurse's rows `is_searchable=0` in one go; flipping 0→1 (with accepting + active variants) makes them searchable. |
|
||
| **`FanOutServiceAreaCommand(nurseId, cityId, districtId)`** | `nurse_service_areas` **add** (b4) | Inserts one index row **per active variant** for the newly-covered area (respecting the `UNIQUE(variant_id, city_id, district_id)` upsert guard), with `is_searchable` per §5. |
|
||
| **`RemoveServiceAreaRowsCommand(nurseId, cityId, districtId)`** | `nurse_service_areas` **remove** (b4) | Deletes (soft-deletes) the index rows for that nurse×area across all variants. Removing an area must drop exactly those rows — **don't collapse areas or you break geo filtering** (§5). |
|
||
| **`RebuildSearchIndexCommand`** [job] | manual admin trigger / first-launch / nightly reconciliation | **Idempotent full rebuild**: truncates+repopulates (or upserts+prunes) the entire index from `nurse_profiles` × `nurse_service_variants` × `nurse_service_areas`, applying §5. This is the convergence/reconciliation path — the index must be **re-derivable from source** at any time. Batched/paginated so it scales. Admin-only endpoint `POST api/v1/admin_search/rebuild_index` (rate-limited, admin policy). |
|
||
|
||
- **Transactionality:** the reindex step runs **inside the same `IUnitOfWork` transaction** as its source
|
||
write (single `CommitAsync`) so the projection can never diverge from the source on a successful commit; a
|
||
source write that rolls back rolls back its index change too. (The seam is shaped so a later Elastic
|
||
feeder can instead consume these as outbox events — see §4 — but the **SQL path applies them inline
|
||
today**.)
|
||
- **No `ISearchIndexWriter` controller surface.** These commands are internal; they are invoked from the
|
||
owning domain's handlers, never exposed as their own REST routes (except the admin rebuild job).
|
||
|
||
### 3.3 The search query + seam (the read side)
|
||
|
||
**`INurseSearch`** (Application contract) — the search-service seam. SQL implementation `SqlNurseSearch`
|
||
(Infrastructure) reads **only** `nurse_search_index WHERE is_searchable = 1`. **All callers depend on the
|
||
interface**, never on raw SQL or an Elastic client, so the MVP→Elastic swap is config-only.
|
||
|
||
**`SearchNursesQuery`** [CORE] — the single family-facing discovery query, delegating to `INurseSearch`.
|
||
- Route: **`GET api/v1/search/nurses`** (public — discovery is pre-auth; **rate-limited** as an unauthenticated
|
||
public endpoint).
|
||
- Filters (all optional except category + city per the product doc's "city required, district optional"):
|
||
- `service_category_id` (required) — the primary dimension.
|
||
- `city_id` (required), `district_id` (optional) — **geography matching:** a city search matches **both**
|
||
the city-only rows (`district_id IS NULL`, "whole city") **and** any row for a district in that city; a
|
||
district search matches that district's rows **plus** the whole-city rows (NULL district covers it). Get
|
||
this exactly right (§5).
|
||
- `nurse_gender` (optional, `male`/`female`) — the **first-class same-gender filter**.
|
||
- `min_price` / `max_price` (optional, IRR `BIGINT`) — price range over the copied `price`.
|
||
- (Optional, surfaced for UI) `price_unit` filter so "per_hour" and "per_day" listings can be compared
|
||
like-for-like; not required.
|
||
- Sort: by `average_rating` **descending** (stable tiebreak on `total_reviews` desc then `nurse_id` so
|
||
paging is deterministic). Rating sort is the only MVP sort.
|
||
- **Always paginated** (`page`/`page_size`, default/max per conventions) — `AsNoTracking()` + `.Select(...)`
|
||
projection to a `NurseSearchResultDto` (nurse_id, variant_id, category, price + unit, nurse_gender,
|
||
average_rating, total_reviews, total_completed_bookings, city/district) — never hydrate entities to map
|
||
them.
|
||
- **Caching:** optionally cache hot (category, city, gender) result pages behind **`ICacheService`** (reuse
|
||
the b0/b1 seam) with a short TTL, invalidated on index writes for the affected city/category — or ship
|
||
no-cache at MVP and add the decorator later. Geo-lookup dropdowns (provinces/cities/districts) are already
|
||
cacheable via b4; don't duplicate them here.
|
||
- **Controller:** `SearchController` (`sealed : BaseController`, inject `ISender`, snake_case
|
||
`[controller]`/`[action]` routes, `base.OperationResult(...)`, `CancellationToken` threaded). Plus
|
||
`AdminSearchController` for the rebuild job (admin policy).
|
||
- **Validators:** FluentValidation on `SearchNursesQuery` (category + city required; `min_price ≤ max_price`;
|
||
`nurse_gender ∈ {male,female}` when present; `page_size` ≤ max).
|
||
|
||
### 3.4 DEFERRED (do not build; leave the seam/pointer)
|
||
|
||
- **Booking-side same-gender capture** — `booking_requests.required_caregiver_gender` (`male`/`female`/`any`)
|
||
and the "surface the chosen gender *into* the booking flow before booking" guarantee are owned by
|
||
**[b8](./backend-phase-8.md)**. This phase makes `nurse_gender` a **first-class search facet** (so families
|
||
can narrow up front) and stops there. (DEFERRED → b8.)
|
||
- **Elasticsearch backend** (`ElasticNurseSearch` behind `INurseSearch`) and the **feeder/outbox daemon**
|
||
that streams source changes into Elastic. DEFERRED — the SQL index is the MVP backend and remains the
|
||
projection/fallback. Build the **seam**, not the Elastic impl. (See §4.)
|
||
- **Availability as a hard filter** — `nurse_availability_slots`/`nurse_availability_exceptions` are **soft
|
||
guidance only**; never block a search result on availability ([`product/business/04-search-and-matching.md`](../../../product/business/04-search-and-matching.md) §(c)). DEFERRED.
|
||
- **Map-based discovery / geocoding** (`IGeocoder` for radius search), **algorithmic ranking beyond rating**,
|
||
**"preferred nurse" continuity-of-carer** suggestions. DEFERRED.
|
||
|
||
## 4. Mocks & seams in this phase
|
||
|
||
| Seam | Owner | Behaviour | Registry |
|
||
| --- | --- | --- | --- |
|
||
| **`INurseSearch`** | **introduced here** | The search-service seam. **MVP impl `SqlNurseSearch` is the real backend, not a mock** — keep it production-grade: it reads `nurse_search_index WHERE is_searchable=1`, applies the category/city/district/gender/price filters, rating sort, and pagination. The DEFERRED `ElasticNurseSearch` is a config-selected drop-in later; callers depend only on `INurseSearch`. | **add a new row** (🟢 SQL is real; Elastic 🟡 deferred) |
|
||
| **`ISearchIndexWriter`** (events) | **introduced here** (shape only) | The index-maintenance seam. The SQL path applies the reindex/fan-out/remove **inline** today (§3.2). Shape it so the same change events can later be routed to an **outbox/queue** for the Elastic feeder instead of an inline upsert — record the swap path, but **do not** build the queue now. | **add a new row** (🟡 outbox deferred) |
|
||
| `ICacheService` | reuse from **b0/b1** | in-memory; optional decorator over hot search result pages + the typed config accessor. | reuse row |
|
||
| `IDateTimeProvider` | reuse from **b0** | stamps `updated_at` on every index upsert (deterministic in tests). | reuse row |
|
||
|
||
The Elastic implementation is a **DI-registered drop-in** behind `INurseSearch` (selection by config,
|
||
**never** an `if (mock)` branch in a handler). Append the `INurseSearch` + `ISearchIndexWriter` rows to
|
||
[`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
|
||
(seam, file, what's real/faked, config keys, **step-by-step how to make it real** — the Elastic client
|
||
package, the index mapping, the feeder daemon that consumes the writer's events via outbox/CDC, the
|
||
config switch that points `INurseSearch` at `ElasticNurseSearch`, and the fact that the SQL index stays
|
||
the fallback/reconciliation source).
|
||
|
||
## 5. Critical rules you must not get wrong
|
||
|
||
- **The visibility invariant is the safety rule.** A row is `is_searchable = 1` **only** when the nurse is
|
||
`is_verified = 1` AND **not suspended** (`nurse_verifications.status != 'suspended'`) AND
|
||
`is_accepting_bookings = 1` AND the variant `is_active = 1`. **An unverified, suspended, paused, or
|
||
deactivated nurse/variant must never appear in results.** Get this wrong and you leak unvetted or
|
||
unbookable nurses to families — the single highest-stakes bug in this phase. Recompute `is_searchable` on
|
||
**every** relevant source write; never trust a stale value.
|
||
- **The bookable unit is the variant, not the nurse.** Search, results, and (later) booking operate on
|
||
`nurse_service_variants`. The index is keyed per-variant-per-area; never collapse a nurse's variants into
|
||
one row.
|
||
- **The index is a read-only projection — never let search mutate source.** All writes re-derive from
|
||
`nurse_profiles` / `nurse_service_variants` / `nurse_service_areas` / `reviews`. The `RebuildSearchIndexCommand`
|
||
must reconstruct the entire index from source with the same result as the incremental hooks — incremental
|
||
maintenance and full rebuild must **converge**. If they can diverge, the maintenance logic is wrong.
|
||
- **Maintain inside the source's transaction.** The reindex runs in the **same unit of work** as the write
|
||
that changed the projected fact (one `CommitAsync`), so a committed source change always carries its index
|
||
change and a rolled-back one carries neither. The projection is written **only by the code path owning the
|
||
source row** — don't reindex a variant from the profile handler, or vice-versa.
|
||
- **`district_id = NULL` means the whole city — a real coverage value, not missing data.** A **city search
|
||
matches both city-only rows (NULL district) and every district row in that city**; a **district search
|
||
matches that district's rows *and* the whole-city (NULL) rows.** NULL participates correctly in the
|
||
`UNIQUE(variant_id, city_id, district_id)` upsert guard. Reimplementing geography as GPS radii is wrong —
|
||
think named districts.
|
||
- **Fan-out cardinality is exact.** One index row per (variant × covered area). Adding a service area
|
||
**inserts** one row per active variant; removing it **deletes** exactly those rows; deactivating a variant
|
||
flips its rows to `is_searchable=0`. Don't collapse, dedupe-away, or orphan rows.
|
||
- **Same-gender matching is near-hard and first-class.** `nurse_gender` is an **exposed, up-front search
|
||
filter** — for bodily-care, a gender mismatch is culturally unacceptable. Make same-gender easy to select
|
||
and the facet prominent; never silently default or drop it. (Carrying the chosen gender *into* the booking
|
||
request is b8.)
|
||
- **Money is IRR `BIGINT`, no floats.** The copied `price` and the `min_price`/`max_price` filters are
|
||
`long`/`BIGINT`; price-unit display + `session_count` totals are not recomputed here. No float path,
|
||
anywhere.
|
||
- **Rating-sort consistency.** The copied `average_rating`/`total_reviews`/`total_completed_bookings` must
|
||
track the source recompute (every review status transition, booking completion/dispute reversal, nightly
|
||
reconciliation). A hidden 1★ review must lower the rating **in the index**, not leave it inflated — which
|
||
is why the rating-recompute path (b3/b14) calls `ReindexNurseCommand`.
|
||
- **Seam discipline.** Controllers/handlers depend on **`INurseSearch`**, never on raw SQL or an Elastic
|
||
client directly, so the MVP→Elastic swap is config-only. The SQL impl is real and production-grade, not a
|
||
throwaway mock.
|
||
- **Availability is soft.** Availability slots/exceptions never hard-filter search results at MVP; the nurse
|
||
still accepts/rejects each request.
|
||
|
||
## 6. Definition of Done
|
||
|
||
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
|
||
- [ ] `nurse_search_index` exists via one migration with its `IEntityTypeConfiguration<T>`, the covering
|
||
search index, the **`UNIQUE(variant_id, city_id, district_id)` filtered** upsert guard (NULL district
|
||
participating), the `nurse_id` secondary index, and soft-delete/audit/`updated_at` wiring.
|
||
- [ ] The §3.2 maintenance commands (`ReindexVariantCommand`, `ReindexNurseCommand`,
|
||
`FanOutServiceAreaCommand`, `RemoveServiceAreaRowsCommand`, `RebuildSearchIndexCommand`) are
|
||
implemented and **wired into the b3/b4/b5/b6 source handlers** so every relevant write maintains the
|
||
index **in the same transaction**. `is_searchable` is recomputed correctly per §5 on each.
|
||
- [ ] `SearchNursesQuery` + `GET api/v1/search/nurses` implemented behind **`INurseSearch`** (`SqlNurseSearch`
|
||
impl), reading **only** `is_searchable=1` rows, with correct NULL-district geography, the same-gender
|
||
filter, price range, rating sort, projected + paginated reads, and the FluentValidation validator.
|
||
- [ ] **`INurseSearch`** (+ the `ISearchIndexWriter` event shape) introduced as Application interfaces with
|
||
Infrastructure impls, **DI-registered via a `ServiceConfiguration/` extension** (config-selected; no
|
||
`if (mock)` in handlers).
|
||
- [ ] Handler/unit tests (NSubstitute): the `is_searchable` predicate (verified+accepting+not-suspended+
|
||
active true; each missing condition false), the geography NULL-district matching, the gender/price
|
||
filters, the rating sort, the fan-out on area add/remove, and the **`is_verified` flip → index
|
||
`is_searchable` update**; ≥1 `WebApplicationFactory` integration test for `/search/nurses` (happy path,
|
||
validation 400). The convergence test: incremental maintenance == full rebuild for a seeded fixture.
|
||
`dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green.
|
||
- [ ] The `Baya.Application/Features/Search/**` area + the `INurseSearch` seam are reflected in the **Project
|
||
map** in `server/CLAUDE.md`.
|
||
- [ ] The contract `dev/contracts/domains/search.md` is written and the `swagger.json` snapshot republished.
|
||
|
||
## 7. How to test (what a human can verify after this phase)
|
||
|
||
Seed (or reuse from prior phases) a small fixture: a province/city with ≥2 districts; **Nurse A** = verified
|
||
(`is_verified=1`, `nurse_verifications.status=approved`), `is_accepting_bookings=1`, female, with one
|
||
**active** elder-care variant priced in IRR and a service area = (city, district 3); **Nurse B** = **not
|
||
verified** (or paused) with an otherwise identical variant + area; **Nurse C** = verified, accepting, male,
|
||
active variant, service area = **whole city** (`district_id=NULL`). Give the nurses different
|
||
`average_rating`s.
|
||
|
||
1. **Verified+accepting+active appears** — `GET api/v1/search/nurses?service_category_id=…&city_id=…` →
|
||
**Nurse A** and **Nurse C** appear; **Nurse B does not** (unverified/paused never surfaces).
|
||
2. **Whole-city vs district geography** — search with `district_id=` district 3 → Nurse A (district 3) **and**
|
||
Nurse C (whole-city NULL row) both match; search the same city with **no district** → both still match;
|
||
search a *different* district in that city → only Nurse C (the whole-city row) matches.
|
||
3. **Same-gender filter** — add `nurse_gender=female` → only Nurse A; `nurse_gender=male` → only Nurse C.
|
||
4. **Price range** — set `min_price`/`max_price` straddling the variants → only the in-range variant(s) appear
|
||
(IRR `BIGINT`, exact).
|
||
5. **Rating sort** — give Nurse A a higher `average_rating` than Nurse C → results come back A-before-C;
|
||
paging is deterministic.
|
||
6. **Flip verification updates searchability** — flip **Nurse A** to suspended/`is_verified=0` (via the b6
|
||
path) → re-run the search → **Nurse A disappears** (its rows' `is_searchable` went 0 in the same
|
||
transaction). Flip back → it reappears.
|
||
7. **Service-area fan-out** — add a second service area to Nurse C → new index rows appear and Nurse C now
|
||
matches that area too; remove the area → those rows (and only those) drop out.
|
||
8. **Variant deactivate** — deactivate Nurse A's variant → it stops appearing (rows `is_searchable=0`),
|
||
without deleting the index rows.
|
||
9. **Rebuild convergence** — `POST api/v1/admin_search/rebuild_index` → the index is identical to its
|
||
incrementally-maintained state (counts and `is_searchable` flags match); no duplicate (variant×area) rows.
|
||
|
||
## 8. Hand off & document (close the phase)
|
||
|
||
- **Docs to update:** the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) (add the
|
||
`Features/Search/**` area, the `nurse_search_index` projection, and the `INurseSearch` seam + where it's
|
||
registered). If you discover/confirm a rule the product docs don't capture (e.g. the exact city↔district
|
||
NULL-matching semantics in the query, or the incremental-vs-rebuild convergence guarantee), record it in
|
||
[`../../../product/business/04-search-and-matching.md`](../../../product/business/04-search-and-matching.md)
|
||
or [`../../../product/data-model/03-services-and-pricing.md`](../../../product/data-model/03-services-and-pricing.md)
|
||
— don't invent rules.
|
||
- **Contract to write:** **`dev/contracts/domains/search.md`** (per
|
||
[`../../contracts/domains/_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) — the public
|
||
`GET api/v1/search/nurses` endpoint (filters: `service_category_id`, `city_id`, optional `district_id`,
|
||
optional `nurse_gender`, optional `min_price`/`max_price`, pagination; rating sort; the
|
||
**NULL-district = whole-city** matching rule documented explicitly), the `NurseSearchResultDto` shape
|
||
(IRR `BIGINT` `price` + `price_unit` enum, `nurse_gender`, rating fields), the admin
|
||
`POST api/v1/admin_search/rebuild_index`, auth/rate-limit notes, and that **only `is_searchable=1` rows are
|
||
ever returned**. Republish the `swagger.json` snapshot per
|
||
[`../../contracts/openapi/README.md`](../../contracts/openapi/README.md). This is what **f6-b7** consumes.
|
||
- **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-7.md` (search
|
||
is live, what **f6** can now build — search + filters (C1), results (C2), nurse profile (C3) — which
|
||
endpoint/contract is live, that the backend is the SQL index with `INurseSearch` allowing a later Elastic
|
||
swap, and that booking-side `required_caregiver_gender` capture lands in b8), append to `backend/STATUS.md`,
|
||
write `dev/shared-working-context/reports/backend-phase-7-report.md` (what was built, **what is now
|
||
testable and exactly how** per §7, what is deferred + how to make it real — Elastic + feeder, contracts
|
||
produced, follow-ups: the booking-side gender capture, the optional result cache, the Elastic backend),
|
||
and update `dev/shared-working-context/reports/mocks-registry.md` (the `INurseSearch` row → 🟢 SQL real,
|
||
Elastic 🟡; the `ISearchIndexWriter` outbox row → 🟡).
|
||
- **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes — the
|
||
`is_searchable` four-condition predicate, the **NULL-district = whole-city** matching (both directions),
|
||
the (variant × area) fan-out cardinality + the `UNIQUE(variant_id, city_id, district_id)` upsert guard,
|
||
the "maintain-in-the-source-transaction, projection owned by the source's code path" rule, the
|
||
incremental↔rebuild convergence requirement, and the `INurseSearch` (SQL-now/Elastic-later) seam — with a
|
||
one-line pointer in `MEMORY.md`.
|