82561c4cc6
Adds the province -> city -> district reference hierarchy (geo schema, seeded with 31 provinces + capital cities + Tehran's 22 districts), nurse service areas (district_id NULL = whole city, filtered-index-pair uniqueness -> 409), and encrypted, geocoded customer addresses with a single-primary invariant. Introduces the IGeocoder seam (mocked) and 409 Conflict on the result envelope. Public cascading lookups are cached behind a generation-token scheme with invalidate-on-admin-write. One EF migration (GeographyAddressesServiceAreas, applied). Contract + swagger snapshot + handoff/report/registry updated. 103 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
62 lines
4.4 KiB
Markdown
62 lines
4.4 KiB
Markdown
# After backend-phase-4 — geography, addresses & nurse service areas are live
|
|
|
|
The geographic spine the marketplace stands on now exists. There is a real province→city→district
|
|
hierarchy (tables, not code lists), nurses can declare where they travel, and customers can save
|
|
encrypted, geocoded service addresses. Contract:
|
|
[`dev/contracts/domains/geography-addresses.md`](../../../contracts/domains/geography-addresses.md);
|
|
machine schema: `dev/contracts/openapi/swagger.v1.json` (refreshed).
|
|
|
|
## What the frontend (f3-b4) can now build
|
|
- **Cascading province/city/district dropdowns** — `GET api/v1/geo/provinces`,
|
|
`…/geo/cities?province_id=`, `…/geo/districts?city_id=` (each active-only, ordered by `sortOrder`), or
|
|
the whole active tree in one call via `GET api/v1/geo/tree`. **An empty district list is normal** — that
|
|
city is whole-city-only; let the user pick "whole city".
|
|
- **Nurse coverage-area editor** — `POST api/v1/nurse_service_areas/add` `{ cityId, districtId? }`
|
|
(omit `districtId` = whole city), `DELETE …/nurse_service_areas/remove/{id}`,
|
|
`GET …/nurse_service_areas/list`. Each row carries an `isWholeCity` flag. Requires a nurse profile first
|
|
(b3 `nurse_profiles/upsert`).
|
|
- **Address book + map-pin picker** — `POST api/v1/customer_addresses/create`
|
|
`{ title, cityId, districtId?, addressLine, postalCode?, recipientName?, recipientPhone?, isPrimary? }`,
|
|
`update/{id}`, `set_primary/{id}`, `delete/{id}`, `list` (primary first). The create/update response and
|
|
the list return `latitude`/`longitude` for the map pin (**nullable** — render a "pin not set" state when
|
|
null), and the address is **decrypted for the owner**.
|
|
|
|
## Rules baked into the API (don't fight them client-side)
|
|
- **`districtId = null` means "the entire city"** — a deliberate coverage choice, not "unset". Show it as a
|
|
first-class option; a whole-city service area matches every district in that city when search lands (b7).
|
|
- **Duplicate service area → `409`** (including a duplicate whole-city row), never a `500`. Surface it as
|
|
"you already cover this".
|
|
- **Single primary address** — the first address is primary automatically; setting another primary clears
|
|
the previous one. There is always exactly one.
|
|
- **`is_active` hides, never deletes** — a deactivated region simply drops out of the dropdowns.
|
|
- **Address PII is encrypted at rest** and only ever returned to the owning customer. Coordinates and the
|
|
`title` label are not PII.
|
|
- **Tenancy** — service areas and addresses are strictly owner-scoped; another owner's id returns `404`.
|
|
- **Refresh after `select_role`** still applies (nurse/customer scoping reads the role claim in the token).
|
|
- **Routes are action-style** (`admin_geo/create_city`, `nurse_service_areas/add`,
|
|
`customer_addresses/create`, …) — see the contract for the full list.
|
|
|
|
## What's mocked
|
|
- **Geocoding (`IGeocoder` → 🟡).** `MockGeocoder` returns deterministic coordinates around the city
|
|
centroid with no network call. A config switch (`Seams:Geocoding:ReturnNullCoordinates`) or a `NO_GEO`
|
|
marker in the address text forces the null-coordinate path so the "saved without a map pin" UI state is
|
|
testable. Real Neshan/Google geocoding is a drop-in registration swap (see mocks-registry).
|
|
|
|
## Schema / migration
|
|
Migration **`20260702093332_GeographyAddressesServiceAreas`** (applies on startup): `geo.Provinces`,
|
|
`geo.Cities`, `geo.Districts`, `geo.NurseServiceAreas`, `usr.CustomerAddresses`. Whole-city uniqueness is a
|
|
**filtered-index pair** (`UNIQUE(nurse_id, city_id) WHERE district_id IS NULL AND deleted_at IS NULL` +
|
|
`UNIQUE(nurse_id, city_id, district_id) WHERE district_id IS NOT NULL AND deleted_at IS NULL`); addresses
|
|
carry a filtered `UNIQUE(customer_id) WHERE is_primary=1 AND deleted_at IS NULL`; coordinates are
|
|
`decimal(9,6)`; address PII columns are encrypted. Seed: 31 provinces (Tehran id `1`), capital cities
|
|
(Tehran city id `101`), Tehran districts `1001…1022`.
|
|
|
|
## Deferred to later phases (do not build against these yet)
|
|
- **`nurse_search_index` fan-out** on service-area add/remove → **b7** (the add/remove handlers are the
|
|
clean trigger point).
|
|
- **GPS-radius / "nurses near me" map discovery** → not planned; coverage is named districts, full stop.
|
|
- **EVV distance check** that *consumes* the address `latitude`/`longitude` → **b9** (this phase only
|
|
*produces* the coordinates).
|
|
- **Region bulk-import feed** (`IGeoDataImporter`) → deferred; the idempotent seed + admin CRUD is enough
|
|
for MVP.
|