Files
baya-monorepo/dev/contracts/domains/geography-addresses.md
T
hamid 82561c4cc6 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>
2026-07-02 16:06:12 +03:30

7.6 KiB

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/money-and-types.md. Machine schema: ../openapi/swagger.v1.json (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.