# 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.
