# 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 `` 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 «` · `».