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>
126 lines
7.6 KiB
Markdown
126 lines
7.6 KiB
Markdown
# 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.
|