# Flow — addresses and map > Last verified: 2026-08-02 against commit `c841bde` **Actor(s):** customer · **Status:** partial **Client:** real · **Server:** real **Business source:** [product/business/01-actors-and-onboarding.md](../../product/business/01-actors-and-onboarding.md) (`customer_addresses`) · pin purpose: [06-evv-and-service-delivery.md](../../product/business/06-evv-and-service-delivery.md) **Integration:** [addresses.md](../integration/domains/addresses.md) · [geography.md](../integration/domains/geography.md) ## What it does The family's address book: where the nurse is asked to come. Each address is a province → city → (optional) district choice plus a free-text street line and a map pin; exactly one address is primary, and the primary one is preselected on the C4 booking-request form. The pin is what the nurse's EVV check-in is later measured against, so it is the reason the map exists at all. ## Screens | Step | Route | Component / notes | | --- | --- | --- | | 1 | `/fa/addresses` | [`(customer)/addresses/page.tsx`](../../client/src/app/%5Blocale%5D/(private-routes)/(customer)/addresses/page.tsx) — `'use client'`, no `generateMetadata`. List / skeleton / `ErrorState` / `EmptyState`, add-edit `FormDialogShell`, delete `ConfirmDialog` | | 1a | — | [`AddressCard.tsx`](../../client/src/components/geography/AddressCard.tsx) — title, «شهر · منطقه», street line, «آدرس اصلی» badge, «پین ثبت شده» / «پین ندارد» | | 2 | dialog | [`AddressForm.tsx`](../../client/src/components/geography/AddressForm.tsx) — react-hook-form. Required: title, city, **pin**, street line. District optional | | 2a | dialog | [`CascadingRegionSelect.tsx`](../../client/src/components/geography/CascadingRegionSelect.tsx) — three `TextField select`s; the empty district option is the explicit «کل شهر» choice (`:152`) | | 2b | dialog | [`AddressMapPicker.tsx`](../../client/src/components/geography/AddressMapPicker.tsx) — **branches on `NESHAN_WEB_KEY` at `:43`**. Key set → `NeshanMap` (Leaflet + Neshan tiles, search box, locate-me, reverse-geocoded preview). Key unset → `GridFallbackMap` (`:78`) | | 3 | `/fa/bookings/request` | C4 reads the book (`page.tsx:116`) and preselects the primary (`:183-184`). **Read-only** — no inline create; the empty state CTA routes to `/fa/addresses` (`:383`) | | 3a | `/fa/profile` | account-hub row «مدیریت آدرس‌ها» → `/fa/addresses` | ### What a tester actually sees on the map `client/.env.development` does **not** define `NEXT_PUBLIC_NESHAN_KEY` (the variable appears only in `.env.production`, commented out, and in `.env.sample`). `config.ts:25` therefore resolves `NESHAN_WEB_KEY = undefined`, so `AddressMapPicker` renders the **grid stand-in**: a 220 px `--bal-primary-soft` panel with a 28 px CSS grid, no tiles, no search box and no locate-me button. Tapping or dragging places a teardrop pin and prints «عرض» / «طول» to 5 decimals underneath. The canvas spans ±0.06° (~±6 km) around the chosen city's centroid (`SPAN`, `:65`). It is a real coordinate emitter, not a decoration — the value it produces is what `create`/`update` send. ## API Shapes belong to [addresses.md](../integration/domains/addresses.md) and [geography.md](../integration/domains/geography.md) — not repeated here. | Call | Endpoint | Notes | | --- | --- | --- | | `useAddresses` | `GET /api/v1/customer_addresses/list` | [`clientApi.ts:46`](../../client/src/services/addresses/apis/clientApi.ts) → `ListMyAddressesQuery`. **Probed** with `T_09120000010`: `total: 1`, `provinceId: 1`, `districtId: 1003`, pin `35.7595 / 51.41`, `postalCode`/`recipientName`/`recipientPhone` all populated | | `useCreateAddress` | `POST /api/v1/customer_addresses/create` | [`CreateAddressCommand.Handler.cs:50-62`](../../server/src/Core/Baya.Application/Features/Addresses/Commands/CreateAddress/CreateAddressCommand.Handler.cs) — a sent pin wins (`GeocodeSources.UserPin`); only a *missing* pin calls `IGeocoder` | | `useUpdateAddress` | `POST /api/v1/customer_addresses/update/{id}` | `UpdateAddressCommand.Handler.cs:62-74` — pin wins; re-geocodes only when city/district/line changed | | `useSetPrimaryAddress` | `POST /api/v1/customer_addresses/set_primary/{id}` | demote + promote in one transaction; the client invalidates the whole list key | | `useDeleteAddress` | `DELETE /api/v1/customer_addresses/delete/{id}` | soft delete behind the global query filter | | `useProvinces` / `useCities` / `useDistricts` | `GET /api/v1/geo/{provinces,cities,districts}` | anonymous, `staleTime: Infinity`. **Probed**: 31 provinces; `province_id=1` → 1 city (101 تهران); `city_id=101` → 22 districts | | — | `GET /api/v1/geo/tree` | live (**probed: 200**) but **unwired** — the client fetches the three levels separately | | map search / reverse | `https://api.neshan.org/v1/search`, `/v5/reverse` | [`geography/neshan.ts`](../../client/src/services/geography/neshan.ts) — a deliberate **direct third-party `fetch`**, bypassing `clientFetch` so our bearer is never sent to Neshan (`:8-9`). Both return `null` immediately while the key is unset (`:29`) | Server seam: `IGeocoder` = `MockGeocoder` (no `Seams:Geocoding:Provider` is set anywhere) — deterministic jitter near the city centroid; an address line containing `NO_GEO` resolves to null coordinates. ## Rules that must hold | Rule | Source | | --- | --- | | **`districtId = null` on an address is optional metadata** — it widens nothing. The same null on a *nurse service area* means whole-city (INV-3). Never coerce it to `0`. | [geography.md](../integration/domains/geography.md) · [nurse-service-areas.md](nurse-service-areas.md) | | **The client-picked pin is authoritative** — the server does not re-geocode over it (REQ-008, delivered). | [addresses.md](../integration/domains/addresses.md) | | **`latitude`/`longitude` are nullable** — null means "saved without a map pin", a display state, never an error. | [addresses.md](../integration/domains/addresses.md) | | **`address_snapshot_json` freezes the address onto the booking** — editing or deleting an address never rewrites history. | [product/business/05](../../product/business/05-booking-and-scheduling.md) §Snapshots | | **A booking request sees a city/district-coarse mask, not the line** — two-stage disclosure (INV-6). | [booking-request.md](booking-request.md) | | **The pin feeds the EVV check-in distance test at `evv_location_tolerance_meters` = 200 m, advisory only** — a mismatch raises a support alert, never blocks or cancels. | [product/business/06](../../product/business/06-evv-and-service-delivery.md) | | **`addressLine`/`postalCode`/`recipientName`/`recipientPhone` are encrypted at rest** and decrypted only for the owner (INV-21). | [product/business/01](../../product/business/01-actors-and-onboarding.md) | | **Exactly one primary**; the first address saved is forced primary (`Handler.cs:42-43`). | server handler | ## How to test 1. Log in as `09120000010` (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md). 2. Go to `/fa/profile` → «مدیریت آدرس‌ها», or straight to `/fa/addresses`. **Expect:** exactly **one** card — «خانه», «تهران · منطقه ۳», the ملاصدرا street line, the «آدرس اصلی» badge and «پین ثبت شده». 3. Tap «افزودن آدرس». Pick استان تهران → شهر تهران. **Expect:** the city select holds exactly one option; the district select then offers «کل شهر» plus 22 «منطقه ۱…۲۲» rows. 4. Submit with the pin untouched. **Expect:** the form blocks with «روی نقشه یک پین بگذارید» — the pin is a required field (`AddressForm.tsx:131`). 5. Tap anywhere on the grid panel, then Save. **Expect:** a «آدرس ذخیره شد» toast, the list refetches, and the new card shows «پین ثبت شده». Confirm server-side: `curl -s --noproxy '*' "http://localhost:5002/api/v1/customer_addresses/list?page=1&pageSize=10" -H "Authorization: Bearer $T_09120000010"` → `total: 2` and non-null `latitude`/`longitude` on the new row. 6. Toggle «انتخاب به‌عنوان آدرس اصلی» on the new card. **Expect:** «آدرس اصلی به‌روزرسانی شد» and the badge moves — never two badges. 7. Delete the address you just created. **Expect:** «آدرس حذف شد» and `total` back to 1. 8. Pick استان اصفهان instead in step 3. **Expect (and this is the defect):** the map panel re-centres on the **Iran centroid** (32.4279, 53.688 — empty desert), not on Isfahan, because real Isfahan is city id `103` while `CITY_CENTROIDS` only knows `301` (see gaps). **Do not edit the seeded address id 1** — the edit dialog silently wipes its postal code and recipient fields (see gaps). Create a throwaway address and edit that instead. The world is shared and 7 days stale, but nothing in this flow depends on the aged booking data. ## Known gaps - `NEXT_PUBLIC_NESHAN_KEY` is unset in `client/.env.development` and commented out in `.env.production`, so **`NeshanMap` never renders anywhere today** — every environment gets the keyless grid stand-in. The real map path (Leaflet, tiles, address search, locate-me, reverse-geocode preview) is entirely unexercised. - `NESHAN_TILE_URL_TEMPLATE` (`geography/constants.ts:51`) and every response field name in `geography/neshan.ts` (`items[].location.x/y`, `formatted_address`) are **UNVERIFIED against the live Neshan API** — both files say so in their own headers. Nobody has ever run this code against `platform.neshan.org`. Setting a key may yield a blank tile layer and a silently empty search box. - `CITY_CENTROIDS` (`geography/constants.ts:27-36`) is keyed on the **mock** seed's city ids (201 Mashhad, 301 Isfahan, 401 Shiraz, …). Live probe: real cities are `101`+`provinceId`-adjacent — Tehran `101`, Karaj `102`, Isfahan `103`. Only Tehran matches. For **every other city** `cityCentroid()` falls back to the Iran centroid, so the picker opens ~400 km from the customer and the ±6 km canvas can never reach their street. - **Editing an address through the UI destroys data.** `AddressForm.submit` (`:91-102`) never collects `postalCode`, `recipientName` or `recipientPhone`; `clientApi.toBody` (`:21-34`) coerces the missing values to `null`; `UpdateAddressCommand.Handler.cs:57-59` assigns them unconditionally. Editing seeded address 1 nulls `1991834511` / «بهرام محمدی» / `09121110010`. Silent, no warning, not recoverable from the UI. - Those same three fields are **never displayed anywhere** either — `AddressCard` shows title/region/line only, so a customer cannot see or set a recipient name for a visit that is not for themselves. - The pin is a **required** form field, so the client can never take the create/update path that has no pin — `IGeocoder` is unreachable from the web app, and the `latitude == null` state (and `AddressCard`'s «پین ندارد» badge) is unreachable except via a direct API call or seeded data. - Geography is seeded **one city per province** (31 provinces, 31 cities; probed `province_id` 2/3/5 → 1 city each) and districts exist **only for Tehran** (`city_id=103` → 0). Outside Tehran the cascade is a two-step formality and «کل شهر» is the only district choice. - `GET /api/v1/geo/tree` is live and returns 200 but no client consumes it; the cascade makes three round trips instead of one. - `AddressMapPicker`'s pin geometry is applied via inline `style` specifically to dodge the RTL stylis plugin — correct, but **fragile and untested for RTL drift**; `AddressMapPicker.test.tsx` only asserts the keyless fallback path. - No `product/` doc describes address entry, the map-pin picker, or the Neshan integration. `business/01` merely lists the `customer_addresses` table; geography is documented **supply-side only** (`business/04` service areas). This flow's UI rules have no business source to check against.