11 KiB
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 Integration: docs/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 + 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. 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. 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 |
| C2/C3 trust badge | GET /api/v1/nurses/{id}/trust_badge |
anonymous, exists, and is not reached — see gaps |
Shapes live in search.md; do not duplicate them here.
Chain traced: useNurseSearch (hooks/useNurseSearch.ts:17) →
searchApi (apis/index.ts:10, USE_SEARCH_MOCK = false at
constants.ts:9) →
clientApi.ts:73-114 →
SearchController.cs:26 →
SearchNursesQuery → SqlNurseSearch.cs:20.
Every link exists. C3: useNurseProfile → clientApi.ts:116 → NursesController.cs:34 → 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 · 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 |
| 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 |
| 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. No token is
needed for the API calls themselves; the screens are behind the customer role guard.
- Open
http://localhost:3000/fa/search. Pick «مراقبت از سالمند» (category 1) and city تهران. Expect: the sticky CTA reads «مشاهدهٔ ۹ پرستار». - 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. - 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. - Open a card. Expect: C3 with 5 services, rating ۵٫۰, one published review, and no distance chip.
- Verify the whole-city invariant without the browser:
Expect:
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"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 isdistrictId: nullon every returned row. - 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 isin_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
SqlNurseSearchnor the client de-duplicates. Live:category=1&city=101→total: 9for one nurse (3 variants × 3 areas). The copy says «۹ پرستار» ("9 nurses",messages/fa.jsonsearch.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:151keys 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/VerificationPanelcalluseNurseTrustBadge→verificationApi, andUSE_VERIFICATION_MOCK = true(verification/constants.ts:9). The mock returns the same fabricated['moh_competency_license','ino_membership']for every nurse id. The real anonymousGET /nurses/{id}/trust_badgeworks (probed: nurse 1 →criminal_record,moh_competency_license,approvedAt2026-07-26) and is simply not reached. - C3 asserts "verified" for an unverified nurse.
nurse/[nurseId]/page.tsx:173renders<TrustBadge state="verified">unconditionally and never readsprofile.isVerified.GET /nurses/3/profilereturns200anonymously withisVerified: false, full name, bio and price list — so a deep link to/fa/search/nurse/3shows «✓ تاییدشده» for a nurse who isin_review. Related to the still-open REQ-067 privacy review; the badge half is a defect regardless. isVerified: trueis fabricated client-side atsearch/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.
LocalDiskObjectStorageyieldsfile:///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. distanceKmis alwaysnull—SqlNurseSearch.cs:62-67hardcodes it because the covering index carries no coordinate. The distance chip onNurseResultCard.tsx:46can never render.topReviewTagis never served (REQ-040), soclientApi.ts:111always mapsnulland the card's tag chip (NurseResultCard.tsx:128) is dead code today.nurseGender: 'female'is hardcoded on the C3 profile mapping (clientApi.ts:154) becauseNursePublicProfileDtocarries 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)behindRoleGuard expected="customer", andPUBLIC_PATHSdoes not include/search, so the middleware redirects to/fa/login. The endpoints are anonymous; the screens are not (REQ-066). search/nursescarries 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, sopageSize=2binds nothing and silently yieldsPagination.DefaultPageSize— which is 50, not the 20 the client'sSEARCH_PAGE_SIZEassumes. Probed:?…&pageSize=2→pageSize: 50.searchClientApisendspage_sizecorrectly; anything else hitting this route will not. attributeChipsis empty for every seeded nurse (probed on nurses 1 and 3), andlatestReview.authorMaskedisnull, which the client maps to''— the C3 snippet renders a bare «· <date>».