Files
baya-monorepo/archive/docs/flows/search-and-discovery.md
T
2026-08-02 20:01:31 +03:30

128 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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>`».