create mvp path
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
# 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>`».
|
||||
Reference in New Issue
Block a user