Files
baya-monorepo/archive/docs/flows/nurse-service-areas.md
T
2026-08-02 20:01:31 +03:30

11 KiB
Raw Blame History

Flow — nurse-service-areas

Last verified: 2026-08-02 against commit c841bde

Actor(s): nurse · Status: partial Client: real · Server: real Business source: product/business/04-search-and-matching.md §(a) Integration: docs/integration/domains/service-areas.md · geography.md

What it does

A nurse declares where she will travel: one or more cities, each either whole-city or a named district. Coverage is the geographic half of being findable — with zero areas the nurse has zero rows in nurse_search_index and no family can reach her, however verified and priced she is. It is add/remove only; there is no edit.

Screens

Step Route Component / notes
1 /fa/nurse/practice «حرفهٔ من» hub. The «مناطق تحت پوشش» row carries a live count off areas.totalNursePracticeScreen.tsx:49
2 /fa/nurse/coverage The whole editor in one 'use client' page.tsx (no thin-RSC split) — coverage/page.tsx
2a Existing areas as MUI Chips. A whole-city row renders «تهران · کل شهر»; a district row «تهران · منطقه ۳» (chipLabel, page.tsx:42-47)
2b Zero areas ⇒ an amber warning card, not an empty state: «تا زمانی که حداقل یک منطقهٔ تحت پوشش اضافه نکنید، در جستجو نمایش داده نمی‌شوید.»
2c Add form = CascadingRegionSelect (province → city → district). City is the only required field.
2d Remove = chip onDelete → MUI confirm Dialog («حذف منطقهٔ تحت پوشش؟»)
/fa/nurse · /fa/nurse/profile/preview Same useServiceAreas query, read-only: the activation checklist's "≥1 coverage area" row (useActivationChecklist.ts:46,63) and the pre-publish dossier

API

Call Endpoint Notes
list GET /api/v1/nurse_service_areas/list?page&pageSize pageSize 100 by default; whole-city rows sorted first (NurseServiceAreaRepository.cs:34)
add POST /api/v1/nurse_service_areas/add duplicate ⇒ 409; also fans the area out into the search index in the same transaction
remove DELETE /api/v1/nurse_service_areas/remove/{id} soft-delete + drop this nurse×area's index rows, same transaction
geo GET /api/v1/geo/{provinces,cities,districts} anonymous; drives the three cascading selects

Shapes: service-areas.md and geography.md.

Chain, verified link by link: coverage/page.tsx:9services/serviceAreas/hooks/*apis/index.ts:10 (USE_SERVICE_AREAS_MOCK = false, constants.ts:8) → apis/clientApi.ts:6clientFetchNurseServiceAreasController.cs:23-36Features/ServiceAreas/{Commands,Queries}NurseServiceAreaRepository. Every link exists.

Rules that must hold

Rule Where it is enforced
districtId = null means WHOLE CITY — an affirmative coverage claim, not missing data. product/business/04 §(a): "a city-level row (no district) means the whole city". INV-3. Client: the district select's empty «کل شهر» option is the choice (CascadingRegionSelect.tsx:141); page.tsx:63 submits region.districtId verbatim. Server: NurseServiceArea.DistrictId nullable, isWholeCity = DistrictId is null (AddNurseServiceArea…Handler.cs:72).
…in both directions. A district search matches that district's rows plus every whole-city row; a city-only search matches all of them. SqlNurseSearch.cs:30-31: r.DistrictId == districtId || r.DistrictId == null. The projection keeps the null verbatim — NurseSearchIndex.cs:44-47, written by SearchIndexMaintainer.NewRow (:222). Live-verified below.
One control owns the whole-city choice. The ui-phase-8 scope toggle was deliberately deleted; an empty district select is a complete, valid submission and never an error. coverage/page.tsx:14-24 (the comment records why), :55-69 — only cityId == null is validated
A duplicate (nurseId, cityId, districtId) is 409, treating null as a real value. Server pre-check DuplicateExistsAsyncConflictResult (AddNurseServiceArea…Handler.cs:43-47); DB backstop is a filtered unique-index pair (NurseServiceAreaConfig.cs:21-29) because SQL Server treats NULLs as distinct. Client fast-path areaExists (types.ts:46-52) plus a 409 fallback (page.tsx:80).
Coverage edits hit the search index in the same transaction — no lag, no reconciliation job. FanOutServiceAreaAsync / RemoveServiceAreaRowsAsync called before CommitAsync (AddNurseServiceArea…Handler.cs:61-62, RemoveNurseServiceArea…Handler.cs:39-40)
Coverage is not part of is_searchable. The gate is is_verified AND is_accepting_bookings AND status != suspended AND variant.is_active (INV-17). Coverage decides whether a row exists at all — the effect is the same (invisible), the mechanism is not. SearchIndexMaintainer.cs:177,248-249. Note service-areas.md calls coverage "one of its four conditions" — imprecise.
404-not-403 on a foreign area id (INV-7). GetOwnedAsync(id, nurseId)NotFoundResult (RemoveNurseServiceArea…Handler.cs:31-33)
Coverage is independent of isAcceptingBookings; a nurse can pause bookings and keep coverage. separate fields; both feed NurseBookable

How to test

Log in as 09120000001 (زهرا عزیزی, verified nurse) — see testing-setup.md.

  1. Go to /fa/nurse/practice → tap «مناطق تحت پوشش». Expect: the row's count badge matches the number of chips on the next screen.
  2. On /fa/nurse/coverage, read the chips. Expect (live, 2026-08-02): تهران · کل شهر, اهواز · کل شهر, تهران · منطقه ۱, تهران · منطقه ۳, تهران · منطقه ۶5 areas, whole-city rows first. ⚠ The seed defines only 3 (whole-city Tehran + districts 1 and 3). Ahvaz (id 8) is pre-existing drift on the shared remote DB and district 6 (id 9) was created by this verification and could not be removed (the bearer token expired mid-probe). Treat "3 seeded areas" as no longer true.
  3. Add a duplicate: province تهران → city تهران → district left at «کل شهر» → «افزودن منطقه». Expect: the inline red «این منطقه از قبل تحت پوشش شماست.», no request fired (client fast path). The server agrees — probed directly: POST add {cityId:101,districtId:null}409 "You already cover this whole city."
  4. Add a genuinely new district in Tehran. Expect: 200, a success snackbar «منطقهٔ تحت پوشش اضافه شد», the form resets, a new chip appears. Probed: {cityId:101,districtId:1006}200, area id 9.
  5. Verify the both-directions rule as a guest (no token needed): GET /api/v1/search/nurses?service_category_id=1&city_id=101&district_id=1006. Expect: nurse 1's rows even though — before step 4 — she had no district-6 area. Measured: total 3, every row districtId: null (the whole-city rows). After step 4 it became total 6 — 3 whole-city rows plus 3 district-6 rows for the same three variants (see gap 1). With no district_id at all: total 9 (3 variants × 3 Tehran areas), matching the boot log's 27 rows / 3 nurses.
  6. Remove a chip → confirm in the dialog. Expect: «منطقهٔ تحت پوشش حذف شد» and the nurse stops appearing in a search for that district. UNVERIFIED live — the token expired before the DELETE landed (401 Token is Not Valid). Code-traced only: soft-delete + RemoveServiceAreaRowsAsync.

Known gaps

  • Whole-city + a specific district in the same city are both accepted, and search then returns the nurse twice. DuplicateExistsAsync (NurseServiceAreaRepository.cs:22-25) compares DistrictId for exact equality, so (101, null) and (101, 1006) are distinct rows; SqlNurseSearch.cs:30-31 then ORs them and does no Distinct on (nurseId, variantId). Live-measured: district-6 search went 3 → 6 results, the same three variants listed twice. The customer sees duplicate cards.
  • Duplicate React keys on the search results list. search/results/page.tsx:151 keys rows ${nurseId}-${variantId}, which is not unique once the row above happens. React logs a duplicate-key warning and both cards render.
  • The coverage screen has no error state. page.tsx:31 destructures only { data, isLoading }; isError is dropped. A failed list renders the amber «هنوز منطقه‌ای ثبت نشده» card, telling a nurse with real coverage that she is invisible in search. This violates the phase-1 "error is never empty" convention that useActivationChecklist follows (:51,55).
  • docs/integration/domains/service-areas.md is wrong on the conflict rule. It states that adding (city, null) when district rows exist "or the reverse" is a conflict. It is not — probed 200. Only an exact (nurse, city, district) repeat conflicts.
  • No edit and no deactivate. NurseServiceAreaDto.isActive is returned and typed client-side but nothing reads or toggles it — there is no endpoint. Changing a district means remove + add, which silently drops the nurse from search between the two calls.
  • No warning that removing the last area de-lists the nurse. The confirm dialog says «دیگر برای ویزیت در این منطقه انتخاب نمی‌شوید» regardless of whether it is the nurse's last area, which is a much bigger consequence.
  • remove is unverified end-to-end — see step 6.
  • The shared demo DB has drifted from DemoWorldDefinitions. Nurse 1 now has 5 service areas, not the 3 the seeder defines, and the seeders are idempotent so they will never correct it.