# Flow — search and discovery

> Last verified: 2026-08-02 against commit `c841bde`

**Actor(s):** customer (guest is aspirational — see gaps) · **Status:** partial
**Client:** partial · **Server:** real
**Business source:** [product/business/04-search-and-matching.md](../../product/business/04-search-and-matching.md)
**Integration:** [docs/integration/domains/search.md](../integration/domains/search.md)

## What it does

A family picks a care category, a city (district optional), an optional same-gender preference, a date
intent and a Toman price range, then browses rating-sorted nurses and opens one nurse's trust dossier
before requesting a booking. Only verified, currently-accepting nurses are ever shown. Discovery is
**filter-only** — there is no free-text search.

## Screens

| Step | Route | Component / notes |
| --- | --- | --- |
| C1 «یافتن پرستار» | `/fa/search` | [`SearchScreen.tsx`](../../client/src/app/%5Blocale%5D/(private-routes)/(customer)/search/SearchScreen.tsx) + [`useSearchFilters.ts`](../../client/src/app/%5Blocale%5D/(private-routes)/(customer)/search/useSearchFilters.ts). `CategoryTile` grid (catalog) · `CascadingRegionSelect` (district blank = whole city) · `GenderToggle allowAny` · `JalaliDateIntentPicker` · Toman price fields (400 ms debounce, `tomanToRial` at the field boundary) · `StickyActionBar` showing the **live result count**. |
| C2 «نتایج جستجو» | `/fa/search/results` | [`results/page.tsx`](../../client/src/app/%5Blocale%5D/(private-routes)/(customer)/search/results/page.tsx). Filter set lives in the URL (deep-linkable cache key); tappable recap chips carry the *whole* query string back to C1. `NurseResultCard` grid, load-more grows `pageSize`, `keepPreviousData` so the list never flashes empty. |
| C3 trust dossier | `/fa/search/nurse/[nurseId]` | [`nurse/[nurseId]/page.tsx`](../../client/src/app/%5Blocale%5D/(private-routes)/(customer)/search/nurse/%5BnurseId%5D/page.tsx). Identity header · tappable `TrustBadge` → `VerificationPanel` dialog · attribute chips · tabs «خدمات» / «نظرات» (published reviews only) · sticky «درخواست رزرو» → `/fa/bookings/request`. |

`sort_static` renders «مرتب‌شده بر اساس امتیاز» as a caption, not a dropdown — rating is the only sort.

## API

| Call | Endpoint | Notes |
| --- | --- | --- |
| C1 live count + C2 list | `GET /api/v1/search/nurses` | anonymous · **snake_case params, `page`/`page_size`** |
| C3 profile | `GET /api/v1/nurses/{id}/profile` | anonymous · one request builds the whole screen |
| C3 reviews tab | `GET /api/v1/nurses/{id}/reviews` | anonymous — [reviews.md](../integration/domains/reviews.md) |
| C2/C3 trust badge | `GET /api/v1/nurses/{id}/trust_badge` | anonymous, **exists, and is not reached** — see gaps |

Shapes live in [search.md](../integration/domains/search.md); do not duplicate them here.

Chain traced: `useNurseSearch` ([`hooks/useNurseSearch.ts:17`](../../client/src/services/search/hooks/useNurseSearch.ts)) →
`searchApi` ([`apis/index.ts:10`](../../client/src/services/search/apis/index.ts), `USE_SEARCH_MOCK = false` at
[`constants.ts:9`](../../client/src/services/search/constants.ts)) →
[`clientApi.ts:73-114`](../../client/src/services/search/apis/clientApi.ts) →
[`SearchController.cs:26`](../../server/src/API/Baya.Web.Api/Controllers/V1/SearchController.cs) →
`SearchNursesQuery` → [`SqlNurseSearch.cs:20`](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Search/SqlNurseSearch.cs).
Every link exists. C3: `useNurseProfile` → `clientApi.ts:116` → [`NursesController.cs:34`](../../server/src/API/Baya.Web.Api/Controllers/V1/NursesController.cs) → `NurseProfileRepository.GetPublicProfileAsync:66`.

## Rules that must hold

| Rule | Where it is enforced |
| --- | --- |
| **`is_searchable` is one gate over four conditions** — verified **and** accepting bookings **and** not suspended **and** the variant is active. Deactivated rows are kept with `is_searchable = 0`, never deleted (INV-17). | [`SearchIndexMaintainer.cs:177,248`](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Search/SearchIndexMaintainer.cs) · read gate at `SqlNurseSearch.cs:24` |
| **`districtId = NULL` means whole city, on both sides** (INV-3). A district search matches that district's rows **plus** the NULL rows; a city-only search matches everything in the city. | `SqlNurseSearch.cs:30-31`; supply side in [geography](../integration/domains/geography.md) |
| The index is maintained **inline in each source write's transaction**, not by a job — a publish is visible in search immediately. | `ISearchIndexMaintainer` |
| Price is IRR Rials, integer. Toman exists only in the C1 input fields. | `NurseSearchIndex.Price` (`long`); `useSearchFilters.ts:16-24` |
| `nurse_gender` accepts only `male`/`female`; **omit it for "any"** — there is no `any` value. | [`SearchNursesQuery.Validator.cs:15-18`](../../server/src/Core/Baya.Application/Features/Search/Queries/SearchNurses/SearchNursesQuery.Validator.cs) |
| Date is **intent only** at MVP — carried into the booking request, never used to filter results. | `SearchScreen.tsx:29-31` |

## How to test

Log in as `09120000010` (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md). No token is
needed for the API calls themselves; the *screens* are behind the customer role guard.

1. Open `http://localhost:3000/fa/search`. Pick «مراقبت از سالمند» (category 1) and city تهران.
   **Expect:** the sticky CTA reads «مشاهدهٔ ۹ پرستار».
2. Tap it. **Expect:** `/fa/search/results?...` with the header «۹ پرستار» — but only **one** distinct
   nurse, زهرا عزیزی, repeated nine times (see the first gap). Her avatar is a broken image.
3. Tap the ✓ تاییدشده chip on any card. **Expect:** a panel listing «پروانه صلاحیت» *and* «نظام پرستاری».
   That is the **mock** — the real badge for nurse 1 is `criminal_record` + `moh_competency_license`.
4. Open a card. **Expect:** C3 with 5 services, rating ۵٫۰, one published review, and no distance chip.
5. Verify the whole-city invariant without the browser:
   ```bash
   curl -s --noproxy '*' "http://localhost:5002/api/v1/search/nurses?service_category_id=1&city_id=101&district_id=1006&page=1&page_size=50"
   ```
   **Expect:** `total: 3` — nurse 1 names districts 1001/1003 only, so these are her three whole-city
   (`districtId: null`) rows surfacing in a district she never enumerated. PASS is `districtId: null` on
   every returned row.
6. Negative control — the unverified nurse must never appear:
   `?service_category_id=3&city_id=101` → **`total: 0`** (nurse 3, مریم احمدی, is the only category-3
   supplier and is `in_review`).

The seeded world supports every step above; unlike the booking flows it is **not** damaged by the
2026-07-26 staleness, because the index is derived, not time-anchored.

## Known gaps

- **The result list is index rows, not nurses.** The index is one row per (variant × area), and neither
  `SqlNurseSearch` nor the client de-duplicates. Live: `category=1&city=101` → `total: 9` for **one**
  nurse (3 variants × 3 areas). The copy says «۹ پرستار» ("9 nurses", `messages/fa.json` `search.results_count`
  / `cta_view_results`), so C1's headline count and C2's header both lie to the customer.
- **Duplicate React keys in C2.** `results/page.tsx:151` keys on `` `${nurseId}-${variantId}` ``, which is
  not unique once the same variant appears under several areas — the nine live rows collapse to three
  distinct keys.
- **C2 and C3's trust dossier is mocked.** `TrustBadge`/`VerificationPanel` call `useNurseTrustBadge` →
  `verificationApi`, and `USE_VERIFICATION_MOCK = true` (`verification/constants.ts:9`). The mock returns
  the same fabricated `['moh_competency_license','ino_membership']` for **every** nurse id. The real
  anonymous `GET /nurses/{id}/trust_badge` works (probed: nurse 1 → `criminal_record`,
  `moh_competency_license`, `approvedAt` 2026-07-26) and is simply not reached.
- **C3 asserts "verified" for an unverified nurse.** `nurse/[nurseId]/page.tsx:173` renders
  `<TrustBadge state="verified">` unconditionally and never reads `profile.isVerified`. `GET /nurses/3/profile`
  returns `200` anonymously with `isVerified: false`, full name, bio and price list — so a deep link to
  `/fa/search/nurse/3` shows «✓ تاییدشده» for a nurse who is `in_review`. Related to the still-open REQ-067
  privacy review; the badge half is a defect regardless.
- **`isVerified: true` is fabricated client-side** at `search/apis/clientApi.ts:101`. Justified by the index
  invariant, but it is a trust signal the client asserts rather than one the server served.
- **Avatars are unusable URLs.** `LocalDiskObjectStorage` yields `file:///C:/Users/.../avatars/nurse/1/….png`
  (probed on `/nurses/1/profile`), which no browser will load. Every seeded nurse photo renders as the
  initials fallback in dev, and the same code path signs the C2 card image.
- **`distanceKm` is always `null`** — `SqlNurseSearch.cs:62-67` hardcodes it because the covering index
  carries no coordinate. The distance chip on `NurseResultCard.tsx:46` can never render.
- **`topReviewTag` is never served** (REQ-040), so `clientApi.ts:111` always maps `null` and the card's tag
  chip (`NurseResultCard.tsx:128`) is dead code today.
- **`nurseGender: 'female'` is hardcoded** on the C3 profile mapping (`clientApi.ts:154`) because
  `NursePublicProfileDto` carries no gender (REQ-042). The page deliberately omits the chip, so the
  fabricated value is unused — but it is a live lie in the typed model.
- **No free-text search** (REQ-041). A customer who knows a nurse's name cannot find her.
- **Guests cannot reach any of it.** All three routes sit in `(private-routes)/(customer)` behind
  `RoleGuard expected="customer"`, and `PUBLIC_PATHS` does not include `/search`, so the middleware
  redirects to `/fa/login`. The endpoints are anonymous; the screens are not (REQ-066).
- **`search/nurses` carries no `[EnableRateLimiting]`** — the one deliberately pre-auth read falls back to
  the 100/min global per-IP limiter (REQ-066, narrower than filed).
- **Pagination footgun.** This is the only endpoint declaring `page_size` (snake). Binding is
  case-insensitive but not separator-insensitive, so `pageSize=2` binds nothing and silently yields
  `Pagination.DefaultPageSize` — which is **50**, not the 20 the client's `SEARCH_PAGE_SIZE` assumes.
  Probed: `?…&pageSize=2` → `pageSize: 50`. `searchClientApi` sends `page_size` correctly; anything else
  hitting this route will not.
- **`attributeChips` is empty for every seeded nurse** (probed on nurses 1 and 3), and
  `latestReview.authorMasked` is `null`, which the client maps to `''` — the C3 snippet renders a bare
  «` · <date>`».
