# 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<T>`, 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<T>` 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}/<Name>/`,
`Baya.Application/Features/ServiceAreas/{Commands|Queries}/<Name>/`, and
`Baya.Application/Features/Addresses/{Commands|Queries}/<Name>/`. 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<T>` 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<T>`, 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`.
