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

11 KiB
Raw Blame History

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 TrustBadgeVerificationPanel 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-114SearchController.cs:26SearchNursesQuerySqlNurseSearch.cs:20. Every link exists. C3: useNurseProfileclientApi.ts:116NursesController.cs:34NurseProfileRepository.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.

  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:
    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=101total: 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=101total: 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 useNurseTrustBadgeverificationApi, 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 nullSqlNurseSearch.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=2pageSize: 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>».