# Backend Phase 4 — Geography, addresses & nurse service areas > **Mission:** lay the geographic spine the whole marketplace stands on. Build the > **province → city → district** reference hierarchy (as *tables*, not hardcoded lists, so new regions > launch without a deploy), seed it with Iran's provinces, major cities, and **Tehran's 22 municipal > districts**; let admins curate it with `is_active`/`sort_order` toggles; serve the **cascading > dropdowns** that every address form and search filter needs; let a **nurse declare where they will > travel** (`nurse_service_areas`, city or city+district, `district_id=NULL` meaning *the whole city*); > and let a **customer save service addresses** with a single enforced primary, encrypted at rest, and > geocoded coordinates behind the **`IGeocoder`** seam. After this phase, catalog/search and booking have > the geography they consume. > > **Track:** backend · **Depends on:** [b3](./backend-phase-3.md) (`nurse_profiles`, `customer_profiles`), [b0](./backend-phase-0.md) (the `IGeocoder` host, `IFieldEncryptor`, REST surface, audit/caching seams), [b1](./backend-phase-1.md) (the seed pattern + typed config accessor) · **Unlocks:** catalog/search ([b5](./backend-phase-5.md)/[b7](./backend-phase-7.md)), booking ([b8](./backend-phase-8.md)); frontend **f3-b4** > **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional. --- ## 1. Context — where this sits This is **backend phase b4**, a near-root reference domain that almost everything downstream consumes. Balinyaar matches families to nurses **by named place**, not by GPS radius: a customer's saved address lives in a `city` (and optionally a `district`), a nurse declares the `cities`/`districts` they will travel to, and search later intersects the two. The geo hierarchy is stored in tables (not a static code list) precisely so a new city or district can be launched by an admin insert — and so `is_active` / `sort_order` give ordered, toggleable dropdowns without ever deleting a region that has historical rows pointing at it. This phase builds that hierarchy, the two membership tables that hang off it (`nurse_service_areas`, `customer_addresses`), and the **`IGeocoder`** seam that turns a typed address into the lat/lng EVV will later check against. **What already exists (do not rebuild) — built by prior phases:** - **`nurse_profiles` & `customer_profiles`** — [b3](./backend-phase-3.md) built the role profile extensions off `users` (`nurse_profiles.id` 1:1 with a nurse `user`; `customer_profiles.id` 1:1 with a customer `user`), plus `patients` and `nurse_bank_accounts` and their tenancy rules. `nurse_service_areas` hangs off `nurse_profiles`; `customer_addresses` hangs off `customer_profiles`. **Reuse those FKs — do not re-create the profiles.** - **The `IGeocoder` host** — [b0](./backend-phase-0.md) reserved the registry row for the geocoding seam (it is *introduced* here, see §4). b0 also built **`IFieldEncryptor`** (the encrypt/decrypt + deterministic `Hash` used by every encrypted PII column — addresses use it), **`ICacheService`** (the in-memory cache the geo-lookup queries read through), **`IDateTimeProvider`**, **`ICurrentUser`** + the audit-field SaveChanges interceptor, the REST controller surface (`sealed : BaseController`, `ISender`, `base.OperationResult(...)`, snake_case routes), rate limiting, and `LoggingBehavior`. - **The seed + typed-config pattern** — [b1](./backend-phase-1.md) established how reference/seed data is loaded into the marketplace baseline (the first migration baseline, the seeding mechanism, the typed cached `platform_configs` accessor). **Mirror b1's seeding mechanism** for the province/city/district seed — do not invent a parallel one. - The b0 foundation generally: Clean-Arch projects, CQRS via **`martinothamar/Mediator`** (`ISender`, `ICommand`/`IQuery`, `internal sealed` handlers), `OperationResult`, Mapster, FluentValidation, `IUnitOfWork`, soft-delete query filters, the `ServiceConfiguration/` registration convention. **What this phase introduces:** the five tables below, the admin/public/nurse/customer capabilities over them, and **one new seam — `IGeocoder`** (the mocked maps/geocoding provider). No GPS-radius model, no map-tile rendering, no availability — those are out of scope (see DEFERRED tags throughout). ## 2. Required reading (do this first) - [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and [`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md) — especially *Persistence* (projection + pagination, one `IEntityTypeConfiguration` per entity, soft-delete filters, **encrypted PII columns go through the field encryptor seam**) and *Performance, caching* (read-heavy reference data is cached behind the cache seam with sensible invalidation). - [`product/data-model/02-geography.md`](../../../product/data-model/02-geography.md) — **the canonical geo schema**: `provinces` 1:N `cities` 1:N `districts` with `sort_order`/`is_active`; `districts` are *optional* (Tehran's 22 مناطق / neighborhoods elsewhere); `nurse_service_areas` with `district_id=NULL` = whole city and `UNIQUE(nurse_id, city_id, district_id)`; **named districts, not a GPS radius**. - [`product/data-model/01-identity-and-access.md`](../../../product/data-model/01-identity-and-access.md) — the **`customer_addresses`** definition (encrypted address + coordinates for EVV, filtered `UNIQUE(customer_id) WHERE is_primary=1`) and how `nurse_service_areas` relates to `nurse_profiles`. - [`product/business/04-search-and-matching.md`](../../../product/business/04-search-and-matching.md) — **why this exists**: geography is driven by nurse-declared service areas; a city-level row (no district) means the whole city; a city search must later match both city-only rows *and* any district row in that city; districts are optional and vary (Tehran's 22 official مناطق vs neighborhoods elsewhere). This is the consumer that makes `district_id=NULL` semantics load-bearing — get them right here. - **The digests** (authoritative per-domain detail): the *Geography* and *Identity & Access → `customer_addresses`/`nurse_service_areas`* sections of `dm_identity_geo_services_verif.md`, and the *Search & Matching → geo* section of `biz_catalog_search.md` (in the run's `digests/`). - **Code to mirror:** b3's `nurse_profiles`/`customer_profiles` configs and the `customer`/`nurse` feature command structure; b1's seeding mechanism + the typed cached config accessor + the first migration baseline it created (you add one migration on top); b0's `IFieldEncryptor` usage on PII columns, the `ICacheService` `GetOrCreateAsync` pattern, and any `ServiceConfiguration/` seam registration. - **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) (envelope, snake_case routes, pagination shape) and [`money-and-types.md`](../../contracts/conventions/money-and-types.md) (not money here, but the id/type conventions — ids are `BIGINT`, coordinates are `decimal`, never `float`). - **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-3.md`, `…-1.md`, `…-0.md`, and `reports/mocks-registry.md` (the `IGeocoder` row you flip from reserved → 🟡). ## 3. Scope — build this Features live under `Baya.Application/Features/Geography/{Commands|Queries}//`, `Baya.Application/Features/ServiceAreas/{Commands|Queries}//`, and `Baya.Application/Features/Addresses/{Commands|Queries}//`. Entities go in `Baya.Domain/Entities/Geography/` (provinces/cities/districts/nurse_service_areas) and `Baya.Domain/Entities/Identity/` (customer_addresses, next to b3's profiles — it is an identity-domain table per the data model). One `IEntityTypeConfiguration` per entity in `Persistence/Configuration/GeographyConfig/` and `…/IdentityConfig/`. **One EF migration** for the five tables. Ids are `BIGINT`; coordinates are `decimal(9,6)` (never `float`/`double`). ### 3.1 Entities + migration **`provinces`** [CORE] — top of the geo hierarchy. - Fields: `id` (BIGINT PK), `name_fa`, `name_en` (NVARCHAR — Persian primary, both required), `sort_order` (int, default 0), `is_active` (BIT, default 1), audit fields, `deleted_at` (soft-delete). - Relations: 1:N → `cities`. **`cities`** [CORE] — the main address/search granularity. - Fields: `id` (BIGINT PK), `province_id` (FK → `provinces`), `name_fa`, `name_en`, `sort_order`, `is_active`, audit fields, `deleted_at`. - Index: `(province_id, sort_order)` for the ordered cascading lookup. - Relations: N:1 → `provinces`; 1:N → `districts`, `nurse_service_areas`, `customer_addresses`. **`districts`** [CORE]/[MVP] — Tehran's 22 municipal مناطق / major neighborhoods elsewhere; **optional**. - Fields: `id` (BIGINT PK), `city_id` (FK → `cities`), `name_fa`, `name_en`, `sort_order`, `is_active`, audit fields, `deleted_at`. - Index: `(city_id, sort_order)`. - Relations: N:1 → `cities`; 1:N → `nurse_service_areas`, `customer_addresses` (both nullable refs). **`nurse_service_areas`** [CORE] — where a nurse will travel; the membership row search later intersects. - Fields: `id` (BIGINT PK), `nurse_id` (FK → `nurse_profiles`), `city_id` (FK → `cities`), `district_id` (FK → `districts`, **NULLABLE**), `is_active` (BIT, default 1), audit fields, `deleted_at`. - **Constraint:** `UNIQUE(nurse_id, city_id, district_id)` — and it **must include the NULL `district_id` rows**. On SQL Server a plain unique index treats NULLs as distinct, which would *wrongly* allow two "whole city" rows for the same nurse+city. Enforce the whole-city uniqueness deliberately: either a **filtered unique index** `UNIQUE(nurse_id, city_id) WHERE district_id IS NULL` *plus* `UNIQUE(nurse_id, city_id, district_id) WHERE district_id IS NOT NULL`, or a computed/sentinel column so the single index covers both. Whichever you pick, a duplicate "whole city" and a duplicate "city+district" are **both** rejected. (See §5 — this is the rule most easily gotten wrong.) - Relations: N:1 → `nurse_profiles`, `cities`, `districts`. **`customer_addresses`** [CORE] — saved service locations; encrypted address + coordinates for EVV. - Fields: `id` (BIGINT PK), `customer_id` (FK → `customer_profiles`), `city_id` (FK → `cities`), `district_id` (FK → `districts`, NULLABLE), `title` (NVARCHAR — "خانه"/"محل کار", a label, not PII), `address_line` (**encrypted** via `IFieldEncryptor`), `postal_code` (**encrypted**, nullable), `latitude` / `longitude` (`decimal(9,6)`, nullable until geocoded), `is_primary` (BIT, default 0), `recipient_name` / `recipient_phone` (**encrypted**, nullable), audit fields, `deleted_at`. - **Constraint:** **filtered `UNIQUE(customer_id) WHERE is_primary=1`** — exactly one primary per customer, enforced at the DB level (the authoritative backstop), not only in the handler. - Relations: N:1 → `customer_profiles`, `cities`, `districts`; later referenced by `booking_requests`/`bookings` (b8/b9 — **DEFERRED here**, just don't preclude it). > **Soft-delete & audit:** every table declares the global `deleted_at IS NULL` query filter and inherits > audit-field stamping from b0's interceptor — handlers never set `CreatedAt`/`CreatedById`. Geo rows are > **deactivated (`is_active=0`) far more often than deleted**, so toggled-off regions vanish from > dropdowns without orphaning the historical addresses/areas that point at them. ### 3.2 Seed data (mirror b1's seeding mechanism) Add a province/city/district seed that runs as part of the b1-style seeding path (idempotent — safe to re-run; key on a stable natural identifier such as `name_en` within parent, not on auto id): - **All 31 Iranian provinces** (`name_fa`/`name_en`, `sort_order` by Iranian convention with Tehran first or alphabetical Persian — keep it deterministic). - **Major cities** at minimum the white-space targets the product calls out: **Tehran, Karaj, Mashhad, Isfahan, Shiraz, Tabriz, Ahvaz, Qom** (plus each province's capital). - **Tehran's 22 municipal districts** (منطقه ۱ … منطقه ۲۲) under the Tehran city row. Other cities get no districts at seed time (whole-city coverage is the default and is meaningful — see §5). Adding neighborhoods elsewhere is an **admin insert later**, no deploy. ### 3.3 Commands & queries (CQRS, `OperationResult`, never throw for expected failures) | Capability | Type | Route | What it does | | --- | --- | --- | --- | | **`ListProvincesQuery`** | Query | `GET api/v1/geo/provinces` | Active provinces ordered by `sort_order`. `AsNoTracking` + `.Select` to a `ProvinceDto`; **cached** via `ICacheService.GetOrCreateAsync` (reference data; invalidate on admin write). Public. | | **`ListCitiesQuery`** | Query | `GET api/v1/geo/cities?province_id=` | Active cities for a province, ordered by `sort_order`. Projected + cached + paginated. Public. | | **`ListDistrictsQuery`** | Query | `GET api/v1/geo/districts?city_id=` | Active districts for a city, ordered. **Empty list is a valid result** (a city with no districts → caller selects whole-city). Projected + cached. Public. | | **`GetGeoTreeQuery`** *(optional convenience)* | Query | `GET api/v1/geo/tree` | The full active province→city→district tree in one cached payload for the cascading dropdown (use if the client prefers one round-trip; otherwise the three lazy queries above suffice). Cached aggressively; invalidated on any admin geo write. Public. | | **`CreateProvinceCommand` / `UpdateProvinceCommand`** | Command | `POST/PUT api/v1/admin_geo/provinces[/{id}]` | Admin create/edit `name_fa`/`name_en`/`sort_order`. Admin policy. **Invalidates the geo cache.** | | **`SetProvinceActiveCommand`** | Command | `POST api/v1/admin_geo/provinces/{id}/set_active` | Toggle `is_active` (no delete). Cascade meaning: deactivating a province hides its cities/districts from public dropdowns (filter on the join, don't bulk-rewrite children). Invalidates cache. | | **`CreateCityCommand` / `UpdateCityCommand` / `SetCityActiveCommand`** | Command | `POST/PUT api/v1/admin_geo/cities[/{id}]`, `…/{id}/set_active` | Same pattern under a `province_id`. | | **`CreateDistrictCommand` / `UpdateDistrictCommand` / `SetDistrictActiveCommand`** | Command | `POST/PUT api/v1/admin_geo/districts[/{id}]`, `…/{id}/set_active` | Same pattern under a `city_id`. | | **`AddNurseServiceAreaCommand`** | Command | `POST api/v1/nurse_service_areas` | The signed-in nurse declares coverage: `{ city_id, district_id? }`. `district_id` omitted/NULL = **whole city**. Validates the city/district exist and are active and that the district belongs to the city; enforces `UNIQUE(nurse_id, city_id, district_id)` — **a duplicate (including a duplicate whole-city row) returns `409`, never a 500**. Tenancy: `nurse_id` from `ICurrentUser`, never the body. *(DEFERRED hook: this is the write that later fans out `nurse_search_index` rows in [b7](./backend-phase-7.md) — leave a clean extension point, do not build the index here.)* | | **`RemoveNurseServiceAreaCommand`** | Command | `DELETE api/v1/nurse_service_areas/{id}` | Soft-removes the nurse's own area (tenancy-checked). *(DEFERRED hook: triggers index row removal in b7.)* | | **`ListMyServiceAreasQuery`** | Query | `GET api/v1/nurse_service_areas` | The nurse's own areas (city + optional district names, "whole city" flag). Tenancy-scoped, projected, paginated. | | **`CreateAddressCommand`** | Command | `POST api/v1/customer_addresses` | Creates an address for the signed-in customer: `{ title, city_id, district_id?, address_line, postal_code?, recipient_name?, recipient_phone?, is_primary? }`. **Encrypts** `address_line`/`postal_code`/recipient fields via `IFieldEncryptor`. **Geocodes** via `IGeocoder.GeocodeAsync(...)` to set `latitude`/`longitude`. If `is_primary=true` (or it is the customer's first address), enforce **single-primary** (clear the prior primary in the same unit of work; the filtered unique index is the backstop). Tenancy from `ICurrentUser`. | | **`UpdateAddressCommand`** | Command | `PUT api/v1/customer_addresses/{id}` | Edit fields; **re-geocode** when the address line/city/district changed; re-encrypt PII. Tenancy-checked. | | **`SetPrimaryAddressCommand`** | Command | `POST api/v1/customer_addresses/{id}/set_primary` | Atomically makes one address primary and clears the previous (single-primary invariant). | | **`DeleteAddressCommand`** | Command | `DELETE api/v1/customer_addresses/{id}` | Soft-delete the customer's own address (tenancy-checked). | | **`ListMyAddressesQuery`** | Query | `GET api/v1/customer_addresses` | The customer's own addresses, **primary first**. Projected (decrypt the address for display *only in the owner's own read*), paginated, tenancy-scoped. | - **Controllers:** `GeoController` (public read), `AdminGeoController` (admin policy; create/update/set_active), `NurseServiceAreasController` (nurse policy, tenancy-scoped), `CustomerAddressesController` (customer policy, tenancy-scoped). All `sealed : BaseController`, inject `ISender`, return `base.OperationResult(...)`, snake_case `[controller]`/`[action]` routes, `CancellationToken` threaded. - **Validators (FluentValidation):** non-empty `name_fa`/`name_en` on geo create/update; valid `province_id`/ `city_id` parents; `AddNurseServiceArea` (city active, district-belongs-to-city when provided); `CreateAddress`/`UpdateAddress` (city active, non-empty `address_line`, district-belongs-to-city when provided, postal-code format if present). - **Mapping:** Mapster in the handler after the projected query (never hydrate entities to map them). ### 3.4 DEFERRED (build the seam/flag, not the feature) - **`nurse_search_index` fan-out** on service-area add/remove — **DEFERRED to [b7](./backend-phase-7.md)**. Leave the add/remove handlers as the clean trigger point; do not build the index table or its maintenance here. - **GPS-radius / map-tile / "nurses near me" map discovery** — **DEFERRED** ([`product/business/04-search-and-matching.md`](../../../product/business/04-search-and-matching.md) §(c)). Geography is **named districts**, full stop. Do not add a radius/distance model. - **Region bulk-import feed** (`IGeoDataImporter` against an official statistics dataset) — **DEFERRED**; the idempotent one-time seed (§3.2) plus admin CRUD is sufficient for MVP. Note it in the report. - **EVV distance check** that *consumes* `customer_addresses.latitude/longitude` — **DEFERRED to [b9](./backend-phase-9.md)**. This phase only *produces* the coordinates. ## 4. Mocks & seams in this phase | Seam | Owner | Mock behaviour | Registry | | --- | --- | --- | --- | | **`IGeocoder`** | **introduced here** | `GeocodeAsync(addressText, cityName, districtName?, ct)` returns a deterministic `(latitude, longitude)` for the input (e.g. a stable hash-derived offset around the city centroid, or an echo of a configured static coordinate per city) plus a `formatted_address` and a `confidence`. **No real network call.** A config switch can force a `null`/`low-confidence` result so the "address saved without coordinates / map-pin-missing" UI states are testable. Coordinates are `decimal`, never `float`. | **flip reserved → 🟡** | | `IFieldEncryptor` | reuse from **b0** | local symmetric key; encrypts `address_line`/`postal_code`/recipient fields; **never logs plaintext PII**; deterministic `Hash` available if a lookup is ever needed. | reuse row | | `ICacheService` | reuse from **b0/b1** | in-memory; the geo-lookup queries read through `GetOrCreateAsync` and admin writes invalidate the geo keys. | reuse row | The mock lives behind a **DI-registered interface** in Infrastructure (real impl is a drop-in later); selection is **config-driven, never an `if (mock)` branch in a handler**. Flip the reserved `IGeocoder` row in [`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) to 🟡 with: the seam (interface + file), what's faked, the config keys it reads, and **step-by-step how to make it real** — register a **Neshan** (or Google) geocoding client implementing `IGeocoder`, the API-key config, the request/response mapping to `(lat, lng, formatted_address, confidence)`, rate-limit/retry, and what to test (a known Tehran address resolves within the expected bounds). ## 5. Critical rules you must not get wrong - **`district_id = NULL` is a MEANINGFUL value ("entire city"), not missing data.** It must participate correctly in the `UNIQUE(nurse_id, city_id, district_id)` constraint (a nurse cannot declare the same whole-city coverage twice) **and** it must be a real coverage choice that later search treats as "matches every district in that city". SQL Server's default NULL-distinct behaviour will silently let duplicate whole-city rows through — defeat that with the filtered-index pair (or sentinel) in §3.1. **Never** treat a NULL district as "the nurse forgot to pick one". - **Geography is named districts, NOT GPS radii.** Do not implement, or leave room to implement, a radius/haversine coverage model. Coverage is the set of `(city, district?)` rows a nurse declared; search intersects rows, not circles. (The address lat/lng exists only for the later EVV *distance check* against the booking site, not for coverage matching.) - **Respect `is_active` — deactivation hides, it never deletes.** A toggled-off province/city/district must disappear from the public cascading dropdowns (the queries filter `is_active=1` at every level and honour the parent's active state), **without** deleting the region or orphaning the addresses/service areas that already reference it. Deletion is reserved for genuinely erroneous rows with no children. - **Exactly one primary address per customer.** Enforce it both in the handler (clearing the prior primary in the same unit of work when a new primary is set) **and** with the filtered `UNIQUE(customer_id) WHERE is_primary=1` index as the authoritative DB backstop — a race that tries to set a second primary fails on the constraint, not silently. The customer's first address is primary by default. - **Addresses are encrypted PII.** `address_line`, `postal_code`, and recipient name/phone go through `IFieldEncryptor` at rest — never stored or logged in plaintext, never returned in a list projection to anyone but the owning customer. Decrypt only in the owner's own read path. (Coordinates and the `title` label are not PII and may stay plaintext.) - **Tenancy.** `nurse_id` on a service area and `customer_id` on an address come from `ICurrentUser`, never from the request body; a nurse/customer can only read and mutate **their own** rows; cross-tenant access returns `404`, not `403` revealing existence. (Booking-time tenancy — an address used in a booking must belong to that booking's customer — is enforced in b8/b9; don't pre-build it, but don't break it.) - **Reference reads are cached, writes invalidate.** The geo-lookup queries are read-heavy and near-static; serve them through `ICacheService` and invalidate the relevant keys on every admin geo write so a newly-activated city appears and a deactivated one disappears promptly. Don't hardcode the list in code. - **Projection + pagination always.** Every read uses `AsNoTracking()` + `.Select(...)` to a DTO and every list is paginated; no unbounded `ToListAsync()`, no entity hydration just to map. ## 6. Definition of Done The shared [definition-of-done.md](../_shared/definition-of-done.md), plus: - [ ] The five tables (`provinces`, `cities`, `districts`, `nurse_service_areas`, `customer_addresses`) exist via **one migration**, each with its `IEntityTypeConfiguration`, soft-delete query filter, and audit wiring; the `nurse_service_areas` whole-city-aware uniqueness and the `customer_addresses` filtered `UNIQUE(customer_id) WHERE is_primary=1` are real DB constraints; coordinates are `decimal(9,6)`; PII columns are encrypted. - [ ] The seed (§3.2) loads provinces + major cities + Tehran's 22 districts **idempotently** and runs via the b1 seeding path. - [ ] All §3.3 commands/queries implemented (CQRS, `OperationResult`, projected + paginated + cached reads, validators), with `GeoController`, `AdminGeoController`, `NurseServiceAreasController`, `CustomerAddressesController`. - [ ] **`IGeocoder`** introduced (Application interface, Infrastructure mock, DI registration via a `ServiceConfiguration/` extension, config-selected). No `if (mock)` in handlers. Address create/update sets coordinates from it. - [ ] Handler unit tests (NSubstitute) for: the cascading lookups, the **duplicate service-area (incl. duplicate whole-city) rejection**, single-primary enforcement (setting a second primary clears the first / is blocked by the filtered index), geocode wiring, and `is_active` filtering. ≥1 `WebApplicationFactory` integration test per controller (happy path, 401, validation 400). `dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green. - [ ] The **Project map** in `server/CLAUDE.md` reflects `Features/Geography/**`, `Features/ServiceAreas/**`, `Features/Addresses/**` and the new `Geography` domain folder + the `IGeocoder` seam; the contract `dev/contracts/domains/geography-addresses.md` is written and the `swagger.json` snapshot republished. ## 7. How to test (what a human can verify after this phase) 1. **Seed ran** — start the API → `GET api/v1/geo/provinces` returns the 31 provinces ordered by `sort_order`; `GET api/v1/geo/cities?province_id={Tehran}` includes Tehran; `GET api/v1/geo/districts?city_id={Tehran}` returns the **22 districts**; `GET api/v1/geo/districts?city_id={Mashhad}` returns an **empty list** (valid — whole-city only). 2. **Cascading dropdown** — `provinces` → pick one → `cities?province_id=` → pick one → `districts?city_id=` each returns only `is_active=1` rows in `sort_order`; (or `GET api/v1/geo/tree` returns the whole active tree in one payload). 3. **Admin toggle** — `POST api/v1/admin_geo/cities/{id}/set_active` (deactivate) → that city **disappears** from `GET api/v1/geo/cities` for its province (and its districts disappear) **without** being deleted; re-activate → it returns. Confirms `is_active` hides, not deletes. 4. **Nurse adds a service area** — as a nurse, `POST api/v1/nurse_service_areas { city_id }` (no district) → `201`, a **whole-city** area; `GET api/v1/nurse_service_areas` shows it flagged "whole city". 5. **Duplicate rejected** — repeat the same `POST` (same city, no district) → **`409`** (the whole-city uniqueness fires, not a 500); add `{ city_id, district_id }` for a district in that city → `201`; repeat that → **`409`**. 6. **Create a geocoded address** — as a customer, `POST api/v1/customer_addresses { title, city_id, address_line, is_primary:true }` → `201` with **`latitude`/`longitude` populated** (from the `IGeocoder` mock); `GET api/v1/customer_addresses` shows it primary-first with the address decrypted for the owner. 7. **Single-primary enforced** — create a second address with `is_primary:true` (or `POST …/{id}/set_primary`) → it becomes primary and the **previous primary is cleared**; an attempt to force two primaries is rejected by the filtered unique index. Confirm only one `is_primary=1` row exists. 8. **PII not leaked** — confirm `address_line` is stored encrypted (inspect the row / a non-owner read does not return the plaintext) and never appears in logs. ## 8. Hand off & document (close the phase) - **Docs to update:** the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) (add the new `Geography` domain + the `Features/Geography|ServiceAreas|Addresses/**` areas and the `IGeocoder` seam); if you confirm/decide a rule the product docs don't capture — e.g. the **filtered-index pair** chosen to make whole-city (`district_id=NULL`) uniqueness real, or the address-is-primary-by-default-on-first rule — record it in [`product/data-model/02-geography.md`](../../../product/data-model/02-geography.md) or [`01-identity-and-access.md`](../../../product/data-model/01-identity-and-access.md) (regenerate the HTML view per `product/CLAUDE.md`). **Don't invent rules.** - **Contract to write:** **`dev/contracts/domains/geography-addresses.md`** (per [`../../contracts/domains/_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) — the public geo lookups (provinces/cities/districts/tree), the admin geo CRUD + set_active endpoints, the nurse service-area add/remove/list, and the customer address CRUD + set_primary; the DTO shapes (`ProvinceDto`, `CityDto`, `DistrictDto`, `NurseServiceAreaDto` with the **"whole city"** flag, `CustomerAddressDto` with **masked** PII and `decimal` coordinates); the `district_id=NULL` ⇒ whole-city semantics spelled out; auth/rate-limit/tenancy notes; the `409` duplicate-area and single-primary side-effects. Republish the `swagger.json` snapshot per [`../../contracts/openapi/README.md`](../../contracts/openapi/README.md). This is what **f3-b4** consumes. - **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-4.md` (geo hierarchy + addresses + service areas are live; what f3 can now build — address book + map-pin picker + cascading province/city/district dropdowns, nurse coverage-area editor; which endpoints/contracts are live; that geocoding is mocked behind `IGeocoder`; the `district_id=NULL` semantics the frontend must honour). Append to `backend/STATUS.md`, write `dev/shared-working-context/reports/backend-phase-4-report.md` (what was built, **what is now testable and exactly how** per §7, what is mocked + how to make it real, contract produced, follow-ups: the b7 search fan-out hook, the DEFERRED EVV distance check, the region bulk-import feed), and update `dev/shared-working-context/reports/mocks-registry.md` (flip the `IGeocoder` row → 🟡). - **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes — the **whole-city (`district_id=NULL`) uniqueness solution** (the filtered-index pair / sentinel), the named-districts-not-GPS-radii rule, the single-primary filtered unique + handler pattern, the address-PII encryption columns, and the `IGeocoder` seam — with a one-line pointer in `MEMORY.md`.