# Backend phase 4 report — Geography, addresses & nurse service areas ## What was built - **New `geo` schema + 5 tables (one migration `GeographyAddressesServiceAreas`):** `Provinces` 1:N `Cities` 1:N `Districts` (reference hierarchy), `NurseServiceAreas` (nurse coverage), and `usr.CustomerAddresses` (identity-domain saved locations). Each has an `IEntityTypeConfiguration`, a `deleted_at IS NULL` soft-delete filter, and audit-field wiring via the b0 interceptor. - **Idempotent seed (b1 `HasData` path):** all 31 Iranian provinces (Tehran first, deterministic `sort_order`), each province's capital city (covers the product's white-space targets — Tehran, Karaj, Mashhad, Isfahan, Shiraz, Tabriz, Ahvaz, Qom), and Tehran's 22 municipal مناطق. Fixed ids (city = `100 + provinceId`; Tehran city `101`; Tehran districts `1001…1022`). - **20 CQRS slices across 4 controllers:** - `GeoController` (public): `provinces`, `cities?province_id=`, `districts?city_id=`, `tree` — projected, cached, active-only with parent-active honoured. - `AdminGeoController` (dynamic-permission): create/update/set_active for province/city/district; every write invalidates the geo cache. - `NurseServiceAreasController` (nurse): `add` (whole-city or city+district), `remove/{id}`, `list`. - `CustomerAddressesController` (customer): `create`, `update/{id}`, `set_primary/{id}`, `delete/{id}`, `list`. - **New `IGeocoder` seam** (Application `Contracts/Common`; `MockGeocoder` in CrossCutting; DI in `AddCrossCuttingSeams`; config `Seams:Geocoding`). Address create/update sets coordinates from it. - **`409 Conflict`** added to the result envelope (`OperationResult.ConflictResult` / `IsConflict` / `BaseController` → 409) — used for duplicate service areas. - Per-domain repositories (`IGeoRepository`, `INurseServiceAreaRepository`, `ICustomerAddressRepository`) on `IUnitOfWork`; encrypted value converters for the address PII columns. ## What is now testable and exactly how (per phase §7) 1. **Seed** — `GET api/v1/geo/provinces` → 31 (Tehran first); `…/geo/cities?province_id=1` includes Tehran (city 101); `…/geo/districts?city_id=101` → 22; `…/geo/districts?city_id=105` (Mashhad) → **empty**. 2. **Cascading dropdown / tree** — the three lazy lookups or `GET api/v1/geo/tree` (one payload). 3. **Admin toggle** — `POST api/v1/admin_geo/set_city_active/{id}` `{isActive:false}` → the city disappears from `geo/cities`; `{isActive:true}` → it returns (not deleted). 4. **Nurse whole-city area** — `POST api/v1/nurse_service_areas/add {cityId:101}` → `isWholeCity:true`. 5. **Duplicate rejected** — repeat the same add → **`409`**; add `{cityId:101, districtId:1001}` → ok; repeat → **`409`**. 6. **Geocoded address** — `POST api/v1/customer_addresses/create {..., isPrimary:true}` → `latitude`/ `longitude` populated; `list` shows it primary-first with the address decrypted for the owner. 7. **Single primary** — a second `isPrimary:true` create (or `set_primary/{id}`) clears the previous; exactly one `isPrimary` row remains. 8. **PII not leaked** — `address_line`/`postal_code`/recipient fields are encrypted at rest; only the owner's own read decrypts them. Covered by tests: **+16 `Baya.Test.Api` integration** (Geo/AdminGeo/NurseServiceAreas/CustomerAddresses — happy path, 401, validation 400, 409 duplicate, single-primary, geocode, is_active hide/show) and **+12 handler unit tests** (NSubstitute — duplicate→conflict, geocode wiring, single-primary clear, tenancy 404, role checks). Full suite: **103 pass**, `dotnet build Baya.sln` with 0 new code warnings. ## Decisions fixed here (recorded in product docs / CLAUDE.md) - **Whole-city (`district_id NULL`) uniqueness = a filtered-index pair** (not a plain unique index, which SQL Server would let duplicate NULLs through). Both filters also exclude soft-deleted rows so a removed area can be re-declared. - **`district_id NULL` is a meaningful "entire city"** coverage value, never "unset". - **Named districts, not GPS radii** — address lat/lng is only for the later EVV distance check. - **Single-primary address** = filtered unique index + clear-then-set in one transaction; first address is primary by default. - **Address PII columns** (`address_line`, `postal_code`, `recipient_name`, `recipient_phone`) encrypted through `IFieldEncryptor`. - **Action-style routes** kept (over the phase's resource-style sketch) for codebase + dynamic-permission consistency. ## Mocked + how to make it real - **`IGeocoder` → 🟡.** `MockGeocoder` = deterministic point around the city centroid (FNV-1a jitter), no network. Config `Seams:Geocoding:{ReturnNullCoordinates, LowConfidenceMarker, ResolvedConfidence}`; the `NO_GEO` marker or the switch forces null coordinates. **Make it real:** add a Neshan (or Google) geocoding client package, add `Seams:Geocoding:{ApiKey,BaseUrl}`, implement `IGeocoder.GeocodeAsync` mapping the vendor response to `(lat, lng, formatted_address, confidence)` (decimal coords), add rate-limit/retry, swap the registration in `AddCrossCuttingSeams` — handlers unchanged; test a known Tehran address resolves within expected bounds. ## Contract produced `dev/contracts/domains/geography-addresses.md` + refreshed `dev/contracts/openapi/swagger.v1.json` (20 new paths). This is what **f3-b4** consumes. ## Follow-ups for later phases - **b7:** wire the `nurse_search_index` fan-out into the (already-isolated) service-area add/remove trigger points. - **b9:** the EVV distance check that consumes `customer_addresses.latitude/longitude`. - **Region bulk-import feed** (`IGeoDataImporter`): deferred; the idempotent seed + admin CRUD suffices for MVP.