backend phase 4: geography, addresses & nurse service areas

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>
This commit is contained in:
hamid
2026-07-02 16:06:12 +03:30
parent 39a979b1a7
commit 82561c4cc6
113 changed files with 9817 additions and 5 deletions
@@ -0,0 +1,125 @@
# Contract — Geography, addresses & nurse service areas (backend phase b4)
> The province→city→district reference hierarchy (public cascading dropdowns + admin curation), a nurse's
> declared service areas, and a customer's saved (encrypted, geocoded) addresses. Assumes
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema:
> [`../openapi/swagger.v1.json`](../openapi/README.md) (refreshed for b4).
**Status:** live as of backend-phase-b4 · **Frontend consumer:** frontend-phase-f3-b4
> **Routing note.** Routes are **action-style** (`[controller]/[action]`, snake_cased) to match the
> codebase convention and the dynamic-permission key scheme — e.g. create-a-city is
> `POST api/v1/admin_geo/create_city`, not `POST api/v1/admin_geo/cities`. Ids for edit/toggle/remove come
> from the **route**, never the body. All responses use the standard `{ succeeded, statusCode, data }`
> envelope; `data` shapes are below.
## Key semantics (read first)
- **`districtId = null` ⇒ whole city.** For a nurse service area it is a real coverage choice ("I cover
the entire city"), not missing data. Search (b7) treats a whole-city row as matching every district in
that city. A city with **no districts** (e.g. Mashhad) is whole-city-only and its district list is a
valid **empty** result.
- **Coverage is named districts, never a GPS radius.** Address coordinates exist only for the later EVV
distance check (b9), not for matching.
- **`is_active` hides, never deletes.** A deactivated province/city/district disappears from the public
dropdowns (parent-active is honoured on the join) without deleting the region or orphaning rows.
- **Exactly one primary address** per customer; the first address is primary by default.
- **Address PII is encrypted at rest** and decrypted only in the owner's own read.
## Public geo lookups — `GeoController` (no auth)
### `GET api/v1/geo/provinces`
- Active provinces, ordered by `sortOrder`. Cached. `data`: `ProvinceDto[]`.
### `GET api/v1/geo/cities?province_id={id}`
- Active cities under an (active) province, ordered. Empty if the province is inactive/absent. `data`: `CityDto[]`.
### `GET api/v1/geo/districts?city_id={id}`
- Active districts under an (active) city, ordered. **Empty list is valid** (whole-city-only city). `data`: `DistrictDto[]`.
### `GET api/v1/geo/tree`
- The full active province→city→district tree in one cached payload. `data`: `ProvinceTreeDto[]`.
## Admin geo curation — `AdminGeoController` (admin / dynamic-permission)
Every write **invalidates the geo cache**. Names required (`nameFa`/`nameEn`); `409` is not used here.
| Route | Body | Result |
| --- | --- | --- |
| `POST admin_geo/create_province` | `{ nameFa, nameEn, sortOrder }` | `ProvinceDto` |
| `POST admin_geo/update_province/{id}` | `{ nameFa, nameEn, sortOrder }` | `ProvinceDto` |
| `POST admin_geo/set_province_active/{id}` | `{ isActive }` | `true` |
| `POST admin_geo/create_city` | `{ provinceId, nameFa, nameEn, sortOrder }` | `CityDto` |
| `POST admin_geo/update_city/{id}` | `{ nameFa, nameEn, sortOrder }` | `CityDto` |
| `POST admin_geo/set_city_active/{id}` | `{ isActive }` | `true` |
| `POST admin_geo/create_district` | `{ cityId, nameFa, nameEn, sortOrder }` | `DistrictDto` |
| `POST admin_geo/update_district/{id}` | `{ nameFa, nameEn, sortOrder }` | `DistrictDto` |
| `POST admin_geo/set_district_active/{id}` | `{ isActive }` | `true` |
- **Failure cases:** `400` invalid names / unknown parent (`provinceId`/`cityId`); `401` unauthenticated;
`403` non-admin; `404` unknown id on update/toggle.
## Nurse service areas — `NurseServiceAreasController` (authenticated; nurse-scoped in handler)
### `POST api/v1/nurse_service_areas/add`
- **Body:** `{ cityId, districtId? }` — omit/`null` `districtId` = whole city.
- **`data`:** `NurseServiceAreaDto`.
- **Failure cases:** `400` invalid/inactive city, or district not in the (active) city; `401`
unauthenticated; `403` caller is not a nurse; **`409`** the nurse already declared this exact coverage
(including a duplicate **whole-city** row) — never a `500`.
- **Tenancy/side effects:** `nurseId` from the caller, never the body. (Deferred: this is the trigger
point for the b7 `nurse_search_index` fan-out.)
### `DELETE api/v1/nurse_service_areas/remove/{id}`
- Soft-removes the nurse's own area. `data`: `true`. `404` if not owned/absent (existence not leaked).
### `GET api/v1/nurse_service_areas/list?page=&page_size=`
- The nurse's own areas, whole-city first, paginated. `data`: `PagedResult<NurseServiceAreaDto>`.
## Customer addresses — `CustomerAddressesController` (authenticated; customer-scoped in handler)
### `POST api/v1/customer_addresses/create`
- **Body:** `{ title, cityId, districtId?, addressLine, postalCode?, recipientName?, recipientPhone?, isPrimary? }`.
- **`data`:** `CustomerAddressDto` (with `latitude`/`longitude` set from the geocoder, or `null` if
unresolved).
- **Behaviour:** encrypts the PII columns; geocodes via `IGeocoder`; the first address (or `isPrimary:true`)
becomes the single primary (prior primary cleared in the same unit of work). A thin customer profile is
auto-provisioned on first address if needed.
- **Failure cases:** `400` empty `title`/`addressLine`, invalid/inactive city, district not in the city,
bad postal-code format; `401`; `403` caller is not a customer.
### `POST api/v1/customer_addresses/update/{id}`
- Edits an owned address; re-geocodes when `addressLine`/`cityId`/`districtId` changes; re-encrypts PII.
`data`: `CustomerAddressDto`. `404` if not owned.
### `POST api/v1/customer_addresses/set_primary/{id}`
- Atomically makes the owned address primary and clears the previous. `data`: `true`. `404` if not owned.
### `DELETE api/v1/customer_addresses/delete/{id}`
- Soft-deletes the owned address. `data`: `true`. `404` if not owned.
### `GET api/v1/customer_addresses/list?page=&page_size=`
- The customer's own addresses, **primary first**, paginated, with PII **decrypted for the owner**. `data`:
`PagedResult<CustomerAddressDto>`.
## Shared shapes
- `ProvinceDto`: `id` (long), `nameFa` (string), `nameEn` (string), `sortOrder` (int).
- `CityDto`: `id`, `provinceId`, `nameFa`, `nameEn`, `sortOrder`.
- `DistrictDto`: `id`, `cityId`, `nameFa`, `nameEn`, `sortOrder`.
- `CityTreeDto`: `id`, `nameFa`, `nameEn`, `sortOrder`, `districts` (`DistrictDto[]`).
- `ProvinceTreeDto`: `id`, `nameFa`, `nameEn`, `sortOrder`, `cities` (`CityTreeDto[]`).
- `NurseServiceAreaDto`: `id`, `cityId`, `cityNameFa`, `cityNameEn`, `districtId` (long?, null = whole
city), `districtNameFa` (null when whole city), `districtNameEn` (null when whole city), `isWholeCity`
(bool), `isActive` (bool).
- `CustomerAddressDto`: `id`, `title`, `cityId`, `cityNameFa`, `cityNameEn`, `districtId` (long?),
`districtNameFa` (null?), `districtNameEn` (null?), `addressLine` (decrypted, owner-only), `postalCode`
(decrypted, owner-only, null?), `latitude` (decimal?, null when ungeocoded), `longitude` (decimal?),
`isPrimary` (bool), `recipientName` (decrypted, null?), `recipientPhone` (decrypted, null?).
## Seed (available on a fresh DB)
31 provinces (Tehran first, `sortOrder` deterministic), each province's capital city (covers Tehran,
Karaj, Mashhad, Isfahan, Shiraz, Tabriz, Ahvaz, Qom), and Tehran's 22 مناطق. Tehran province id `1`,
Tehran city id `101`, Tehran districts `1001…1022`; other cities have no districts at seed time.
## Changelog
- b4 — initial contract: public geo lookups, admin geo CRUD + set_active, nurse service areas, customer
addresses; `IGeocoder` seam; `409` conflict added to the envelope.