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

99 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.