Files
baya-monorepo/dev/phases/backend/backend-phase-4.md
T
2026-06-28 21:59:59 +03:30

333 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.