12 KiB
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
(customer_addresses) · pin purpose: 06-evv-and-service-delivery.md
Integration: addresses.md · 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 — 'use client', no generateMetadata. List / skeleton / ErrorState / EmptyState, add-edit FormDialogShell, delete ConfirmDialog |
| 1a | — | AddressCard.tsx — title, «شهر · منطقه», street line, «آدرس اصلی» badge, «پین ثبت شده» / «پین ندارد» |
| 2 | dialog | AddressForm.tsx — react-hook-form. Required: title, city, pin, street line. District optional |
| 2a | dialog | CascadingRegionSelect.tsx — three TextField selects; the empty district option is the explicit «کل شهر» choice (:152) |
| 2b | dialog | 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 and geography.md — not repeated here.
| Call | Endpoint | Notes |
|---|---|---|
useAddresses |
GET /api/v1/customer_addresses/list |
clientApi.ts:46 → 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 — 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 — 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 · nurse-service-areas.md |
| The client-picked pin is authoritative — the server does not re-geocode over it (REQ-008, delivered). | addresses.md |
latitude/longitude are nullable — null means "saved without a map pin", a display state, never an error. |
addresses.md |
address_snapshot_json freezes the address onto the booking — editing or deleting an address never rewrites history. |
product/business/05 §Snapshots |
| A booking request sees a city/district-coarse mask, not the line — two-stage disclosure (INV-6). | 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 |
addressLine/postalCode/recipientName/recipientPhone are encrypted at rest and decrypted only for the owner (INV-21). |
product/business/01 |
Exactly one primary; the first address saved is forced primary (Handler.cs:42-43). |
server handler |
How to test
- Log in as
09120000010(سارا محمدی, customer) — see testing-setup.md. - Go to
/fa/profile→ «مدیریت آدرسها», or straight to/fa/addresses. Expect: exactly one card — «خانه», «تهران · منطقه ۳», the ملاصدرا street line, the «آدرس اصلی» badge and «پین ثبت شده». - Tap «افزودن آدرس». Pick استان تهران → شهر تهران. Expect: the city select holds exactly one option; the district select then offers «کل شهر» plus 22 «منطقه ۱…۲۲» rows.
- Submit with the pin untouched. Expect: the form blocks with «روی نقشه یک پین بگذارید» — the pin is a
required field (
AddressForm.tsx:131). - 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: 2and non-nulllatitude/longitudeon the new row. - Toggle «انتخاب بهعنوان آدرس اصلی» on the new card. Expect: «آدرس اصلی بهروزرسانی شد» and the badge moves — never two badges.
- Delete the address you just created. Expect: «آدرس حذف شد» and
totalback to 1. - 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
103whileCITY_CENTROIDSonly knows301(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_KEYis unset inclient/.env.developmentand commented out in.env.production, soNeshanMapnever 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 ingeography/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 againstplatform.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 are101+provinceId-adjacent — Tehran101, Karaj102, Isfahan103. Only Tehran matches. For every other citycityCentroid()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 collectspostalCode,recipientNameorrecipientPhone;clientApi.toBody(:21-34) coerces the missing values tonull;UpdateAddressCommand.Handler.cs:57-59assigns them unconditionally. Editing seeded address 1 nulls1991834511/ «بهرام محمدی» /09121110010. Silent, no warning, not recoverable from the UI. - Those same three fields are never displayed anywhere either —
AddressCardshows 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 —
IGeocoderis unreachable from the web app, and thelatitude == nullstate (andAddressCard'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_id2/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/treeis 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 inlinestylespecifically to dodge the RTL stylis plugin — correct, but fragile and untested for RTL drift;AddressMapPicker.test.tsxonly asserts the keyless fallback path.- No
product/doc describes address entry, the map-pin picker, or the Neshan integration.business/01merely lists thecustomer_addressestable; geography is documented supply-side only (business/04service areas). This flow's UI rules have no business source to check against.