# 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](../../product/business/04-search-and-matching.md) §(a)
**Integration:** [docs/integration/domains/service-areas.md](../integration/domains/service-areas.md) ·
[geography.md](../integration/domains/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`](../../client/src/app/%5Blocale%5D/(private-routes)/nurse/practice/NursePracticeScreen.tsx) |
| 2 | `/fa/nurse/coverage` | The whole editor in one `'use client'` `page.tsx` (no thin-RSC split) — [`coverage/page.tsx`](../../client/src/app/%5Blocale%5D/(private-routes)/nurse/coverage/page.tsx) |
| 2a | ″ | Existing areas as MUI `Chip`s. 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`](../../client/src/components/geography/CascadingRegionSelect.tsx) (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`](../../client/src/components/ActivationChecklist/useActivationChecklist.ts)) 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](../integration/domains/service-areas.md) and
[geography.md](../integration/domains/geography.md).

**Chain, verified link by link:** `coverage/page.tsx:9` → `services/serviceAreas/hooks/*` →
[`apis/index.ts:10`](../../client/src/services/serviceAreas/apis/index.ts) (`USE_SERVICE_AREAS_MOCK = false`,
[`constants.ts:8`](../../client/src/services/serviceAreas/constants.ts)) →
[`apis/clientApi.ts:6`](../../client/src/services/serviceAreas/apis/clientApi.ts) → `clientFetch` →
[`NurseServiceAreasController.cs:23-36`](../../server/src/API/Baya.Web.Api/Controllers/V1/NurseServiceAreasController.cs)
→ `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](../../product/business/04-search-and-matching.md) §(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`](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Search/SqlNurseSearch.cs): `r.DistrictId == districtId \|\| r.DistrictId == null`. The projection keeps the null verbatim — [`NurseSearchIndex.cs:44-47`](../../server/src/Core/Baya.Domain/Entities/Search/NurseSearchIndex.cs), 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](../integration/domains/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](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](../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.
