11 KiB
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.total — NursePracticeScreen.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:9 → services/serviceAreas/hooks/* →
apis/index.ts:10 (USE_SERVICE_AREAS_MOCK = false,
constants.ts:8) →
apis/clientApi.ts:6 → clientFetch →
NurseServiceAreasController.cs:23-36
→ Features/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 DuplicateExistsAsync → ConflictResult (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.
- Go to
/fa/nurse/practice→ tap «مناطق تحت پوشش». Expect: the row's count badge matches the number of chips on the next screen. - 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. - 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." - 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, areaid 9. - 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 rowdistrictId: null(the whole-city rows). After step 4 it becametotal 6— 3 whole-city rows plus 3 district-6 rows for the same three variants (see gap 1). With nodistrict_idat all:total 9(3 variants × 3 Tehran areas), matching the boot log's 27 rows / 3 nurses. - 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
DELETElanded (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) comparesDistrictIdfor exact equality, so(101, null)and(101, 1006)are distinct rows;SqlNurseSearch.cs:30-31then ORs them and does noDistincton(nurseId, variantId). Live-measured: district-6 search went3 → 6results, the same three variants listed twice. The customer sees duplicate cards. - Duplicate React keys on the search results list.
search/results/page.tsx:151keys 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:31destructures only{ data, isLoading };isErroris dropped. A failedlistrenders 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 thatuseActivationChecklistfollows (: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 — probed200. Only an exact(nurse, city, district)repeat conflicts. - No edit and no deactivate.
NurseServiceAreaDto.isActiveis 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.
removeis 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.