backend phase 4: geography, addresses & nurse service areas

Adds the province -> city -> district reference hierarchy (geo schema,
seeded with 31 provinces + capital cities + Tehran's 22 districts),
nurse service areas (district_id NULL = whole city, filtered-index-pair
uniqueness -> 409), and encrypted, geocoded customer addresses with a
single-primary invariant. Introduces the IGeocoder seam (mocked) and
409 Conflict on the result envelope. Public cascading lookups are cached
behind a generation-token scheme with invalidate-on-admin-write.

One EF migration (GeographyAddressesServiceAreas, applied). Contract +
swagger snapshot + handoff/report/registry updated. 103 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamid
2026-07-02 16:06:12 +03:30
parent 39a979b1a7
commit 82561c4cc6
113 changed files with 9817 additions and 5 deletions
@@ -54,6 +54,8 @@
<p><strong>Role:</strong> The person receiving care, <strong>separate from the payer</strong>. <strong>Why:</strong> the payer (adult child, spouse) is usually not the patient (elderly parent, newborn, post-surgical adult); one customer registers many patients, each with its own clinical baseline and longitudinal record. Unchanged: <code>id</code>, <code>customer_id</code>, <code>display_name</code>, <code>first_name</code>, <code>last_name</code>, <code>birth_date</code>, <code>gender</code>, <code>blood_type</code>, <code>initial_medical_notes</code> (enc), <code>is_active</code>, timestamps. <strong>Relations:</strong> N:1 → <code>customer_profiles</code>; 1:N → <code>booking_requests</code>, <code>patient_care_records</code>. <strong>Tenancy invariant:</strong> a <code>booking_request.patient_id</code> must belong to the same <code>customer_id</code>.</p>
<h3 id="customer_addresses-core"><code>customer_addresses</code> [CORE] <a class="anchor" href="#customer_addresses-core" aria-hidden="true">#</a></h3>
<p><strong>Role:</strong> Saved service locations; the encrypted address + coordinates for EVV distance checks. <strong>Why coordinates:</strong> EVV check-in compares the nurse's GPS against the booking address within tolerance. Unchanged fields, plus: <strong>filtered <code>UNIQUE(customer_id) WHERE is_primary=1</code></strong> so exactly one primary exists (prevents ambiguous default). <strong>Relations:</strong> N:1 → <code>customer_profiles</code>, <code>cities</code>, <code>districts</code>; referenced by <code>booking_requests</code>/<code>bookings</code>.</p>
<blockquote><p><strong>As-built (backend-phase-4):</strong> <code>usr.CustomerAddresses</code><code>address_line</code>, <code>postal_code</code>, <code>recipient_name</code> and <code>recipient_phone</code> are <strong>encrypted at rest</strong> via <code>IFieldEncryptor</code> and returned only in the owning customer's own read (decrypted); <code>title</code> and coordinates (<code>decimal(9,6)</code>, nullable until geocoded) stay plaintext. The <strong>first</strong> address is primary by default; <code>set_primary</code> thereafter clears the prior primary and sets the new one in one transaction, with the filtered <code>UNIQUE(customer_id) WHERE is_primary=1 AND deleted_at IS NULL</code> index as the DB backstop. Coordinates are produced by the mocked <strong><code>IGeocoder</code></strong> seam on create/update (nullable when the address can't be resolved) and are consumed by the EVV distance check only later (b9), never for coverage matching.</p>
</blockquote>
<h3 id="nurse_bank_accounts-core"><code>nurse_bank_accounts</code> [CORE] <a class="anchor" href="#nurse_bank_accounts-core" aria-hidden="true">#</a></h3>
<p><strong>Role:</strong> Payout destination (IBAN/Sheba). <strong>Why hardened:</strong> the IBAN is the single place real money leaves the platform — the original "admin eyeballs the IBAN" check is exactly the forgeable, money-mule-risk link the research warns about.</p>
<div class="table-wrap"><table><thead><tr><th>Field</th><th>Type</th><th>Notes</th></tr></thead><tbody>
@@ -55,6 +55,15 @@ Fields unchanged from baseline: `id`, `email` (enc, nullable), `phone` (enc, uni
### `customer_addresses` [CORE]
**Role:** Saved service locations; the encrypted address + coordinates for EVV distance checks. **Why coordinates:** EVV check-in compares the nurse's GPS against the booking address within tolerance. Unchanged fields, plus: **filtered `UNIQUE(customer_id) WHERE is_primary=1`** so exactly one primary exists (prevents ambiguous default). **Relations:** N:1 → `customer_profiles`, `cities`, `districts`; referenced by `booking_requests`/`bookings`.
> **As-built (backend-phase-4):** `usr.CustomerAddresses` — `address_line`, `postal_code`,
> `recipient_name` and `recipient_phone` are **encrypted at rest** via `IFieldEncryptor` and returned only
> in the owning customer's own read (decrypted); `title` and coordinates (`decimal(9,6)`, nullable until
> geocoded) stay plaintext. The **first** address is primary by default; `set_primary` thereafter clears
> the prior primary and sets the new one in one transaction, with the filtered
> `UNIQUE(customer_id) WHERE is_primary=1 AND deleted_at IS NULL` index as the DB backstop. Coordinates are
> produced by the mocked **`IGeocoder`** seam on create/update (nullable when the address can't be
> resolved) and are consumed by the EVV distance check only later (b9), never for coverage matching.
### `nurse_bank_accounts` [CORE]
**Role:** Payout destination (IBAN/Sheba). **Why hardened:** the IBAN is the single place real money leaves the platform — the original "admin eyeballs the IBAN" check is exactly the forgeable, money-mule-risk link the research warns about.
+2
View File
@@ -21,6 +21,8 @@
<p><strong>Role:</strong> The geo hierarchy backing service areas, addresses, and search. <strong>Why a table, not a static list:</strong> new cities/districts launch without a deploy, and <code>sort_order</code>/<code>is_active</code> drive ordered, toggleable dropdowns. <code>districts</code> map to Tehran's 22 municipal districts or major neighborhoods elsewhere; they are <strong>optional</strong> (a nurse can cover a whole city). Fields unchanged. <strong>Relations:</strong> <code>provinces</code> 1:N <code>cities</code> 1:N <code>districts</code>; referenced by <code>customer_addresses</code> and <code>nurse_service_areas</code>.</p>
<h3 id="nurse_service_areas-core"><code>nurse_service_areas</code> [CORE] <a class="anchor" href="#nurse_service_areas-core" aria-hidden="true">#</a></h3>
<p><strong>Role:</strong> Where a nurse will travel. A row with <code>district_id = NULL</code> means the entire city. <strong>Why a join table (not a radius):</strong> Iranian nurses think in named districts, not GPS radii; this also drives the geographic filter in search cheaply. Unchanged, with <code>UNIQUE(nurse_id, city_id, district_id)</code>. <strong>Relations:</strong> N:1 → <code>nurse_profiles</code>, <code>cities</code>, <code>districts</code>.</p>
<blockquote><p><strong>As-built (backend-phase-4):</strong> the geo hierarchy lives in a <strong><code>geo</code> schema</strong> (<code>Provinces</code>/<code>Cities</code>/ <code>Districts</code>/<code>NurseServiceAreas</code>), seeded via <code>HasData</code> with all 31 provinces, each province's capital city, and Tehran's 22 مناطق. Because SQL Server treats NULLs as distinct in a unique index, the whole-city (<code>district_id = NULL</code>) uniqueness is enforced with a <strong>filtered-index pair</strong><code>UNIQUE(nurse_id, city_id) WHERE district_id IS NULL AND deleted_at IS NULL</code> <strong>plus</strong> <code>UNIQUE(nurse_id, city_id, district_id) WHERE district_id IS NOT NULL AND deleted_at IS NULL</code> — so both a duplicate whole-city row and a duplicate city+district row are rejected (surfaced as <code>409</code>), while a soft-removed area can be re-declared. Public lookups are cached and filter <code>is_active</code> at every level (a deactivated parent hides its children); geocoding is behind the mocked <strong><code>IGeocoder</code></strong> seam.</p>
</blockquote>
<a class="back-to-top" href="#">↑ Back to top</a>
</div></main>
</div>
+10
View File
@@ -7,3 +7,13 @@
### `nurse_service_areas` [CORE]
**Role:** Where a nurse will travel. A row with `district_id = NULL` means the entire city. **Why a join table (not a radius):** Iranian nurses think in named districts, not GPS radii; this also drives the geographic filter in search cheaply. Unchanged, with `UNIQUE(nurse_id, city_id, district_id)`. **Relations:** N:1 → `nurse_profiles`, `cities`, `districts`.
> **As-built (backend-phase-4):** the geo hierarchy lives in a **`geo` schema** (`Provinces`/`Cities`/
> `Districts`/`NurseServiceAreas`), seeded via `HasData` with all 31 provinces, each province's capital
> city, and Tehran's 22 مناطق. Because SQL Server treats NULLs as distinct in a unique index, the
> whole-city (`district_id = NULL`) uniqueness is enforced with a **filtered-index pair** —
> `UNIQUE(nurse_id, city_id) WHERE district_id IS NULL AND deleted_at IS NULL` **plus**
> `UNIQUE(nurse_id, city_id, district_id) WHERE district_id IS NOT NULL AND deleted_at IS NULL` — so both a
> duplicate whole-city row and a duplicate city+district row are rejected (surfaced as `409`), while a
> soft-removed area can be re-declared. Public lookups are cached and filter `is_active` at every level
> (a deactivated parent hides its children); geocoding is behind the mocked **`IGeocoder`** seam.