4.4 KiB
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;
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 bysortOrder), or the whole active tree in one call viaGET 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? }(omitdistrictId= whole city),DELETE …/nurse_service_areas/remove/{id},GET …/nurse_service_areas/list. Each row carries anisWholeCityflag. Requires a nurse profile first (b3nurse_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 returnlatitude/longitudefor 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 = nullmeans "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 a500. 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_activehides, 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
titlelabel are not PII. - Tenancy — service areas and addresses are strictly owner-scoped; another owner's id returns
404. - Refresh after
select_rolestill 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→ 🟡).MockGeocoderreturns deterministic coordinates around the city centroid with no network call. A config switch (Seams:Geocoding:ReturnNullCoordinates) or aNO_GEOmarker 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_indexfan-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.