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:
@@ -12,6 +12,28 @@ One block per completed backend phase. Newest at the top. Backend lane writes he
|
||||
- **Notes for frontend:** <anything load-bearing>
|
||||
-->
|
||||
|
||||
## backend-phase-4 — Geography, addresses & nurse service areas — 2026-07-02
|
||||
- **Shipped:** five tables via one migration (`GeographyAddressesServiceAreas`) — new **`geo`** schema
|
||||
`Provinces` 1:N `Cities` 1:N `Districts` (+ `NurseServiceAreas`) and `usr.CustomerAddresses`; seed
|
||||
(31 provinces + capital cities + Tehran's 22 مناطق via `HasData`); 20 CQRS slices across 4 controllers
|
||||
(`geo` public lookups incl. `/tree`, `admin_geo` CRUD + set_active, `nurse_service_areas`,
|
||||
`customer_addresses`); new **`IGeocoder`** seam (deterministic mock, `Seams:Geocoding`); per-domain
|
||||
repos on `IUnitOfWork`; enc value converters for the address PII columns; **`409 Conflict`** added to
|
||||
`OperationResult`/`BaseController`. Whole-city (`district_id NULL`) uniqueness via a **filtered-index
|
||||
pair**; single-primary address via filtered `UNIQUE(customer_id) WHERE is_primary=1`; geo reads cached
|
||||
behind a generation-token scheme with invalidate-on-admin-write.
|
||||
- **Contracts:** dev/contracts/domains/geography-addresses.md + openapi snapshot refreshed (yes — 20 new
|
||||
geo/service-area/address paths).
|
||||
- **Mocked:** `IGeocoder` → 🟡 (see reports/mocks-registry.md).
|
||||
- **Gate:** build clean (0 new code warnings) / tests green (103 pass: +16 `Baya.Test.Api` integration,
|
||||
+12 handler unit tests). Migration `GeographyAddressesServiceAreas` applies on startup; swagger exposes
|
||||
all b4 paths.
|
||||
- **Handoff:** backend/handoff/after-backend-phase-4.md
|
||||
- **Notes for frontend:** `districtId=null` means **whole city** (a real choice) everywhere. Duplicate
|
||||
service area → **409**. Addresses come back **decrypted for the owner** with `latitude`/`longitude`
|
||||
(nullable when ungeocoded — geocoding is mocked). Routes are action-style (`admin_geo/create_city`,
|
||||
`nurse_service_areas/add`, `customer_addresses/create`, …). Admin geo needs an admin token.
|
||||
|
||||
## backend-phase-3 — Identity: profiles, patients & nurse bank accounts — 2026-07-02
|
||||
- **Shipped:** four `usr` tables via one migration (`IdentityProfilesPatientsBankAccounts`) —
|
||||
`NurseProfiles` (1:1 `Users`; guarded `is_verified` **no public setter**; read-only aggregates;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# After backend-phase-4 — geography, addresses & nurse service areas are live
|
||||
|
||||
The geographic spine the marketplace stands on now exists. There is a real province→city→district
|
||||
hierarchy (tables, not code lists), nurses can declare where they travel, and customers can save
|
||||
encrypted, geocoded service addresses. Contract:
|
||||
[`dev/contracts/domains/geography-addresses.md`](../../../contracts/domains/geography-addresses.md);
|
||||
machine schema: `dev/contracts/openapi/swagger.v1.json` (refreshed).
|
||||
|
||||
## What the frontend (f3-b4) can now build
|
||||
- **Cascading province/city/district dropdowns** — `GET api/v1/geo/provinces`,
|
||||
`…/geo/cities?province_id=`, `…/geo/districts?city_id=` (each active-only, ordered by `sortOrder`), or
|
||||
the whole active tree in one call via `GET api/v1/geo/tree`. **An empty district list is normal** — that
|
||||
city is whole-city-only; let the user pick "whole city".
|
||||
- **Nurse coverage-area editor** — `POST api/v1/nurse_service_areas/add` `{ cityId, districtId? }`
|
||||
(omit `districtId` = whole city), `DELETE …/nurse_service_areas/remove/{id}`,
|
||||
`GET …/nurse_service_areas/list`. Each row carries an `isWholeCity` flag. Requires a nurse profile first
|
||||
(b3 `nurse_profiles/upsert`).
|
||||
- **Address book + map-pin picker** — `POST api/v1/customer_addresses/create`
|
||||
`{ title, cityId, districtId?, addressLine, postalCode?, recipientName?, recipientPhone?, isPrimary? }`,
|
||||
`update/{id}`, `set_primary/{id}`, `delete/{id}`, `list` (primary first). The create/update response and
|
||||
the list return `latitude`/`longitude` for the map pin (**nullable** — render a "pin not set" state when
|
||||
null), and the address is **decrypted for the owner**.
|
||||
|
||||
## Rules baked into the API (don't fight them client-side)
|
||||
- **`districtId = null` means "the entire city"** — a deliberate coverage choice, not "unset". Show it as a
|
||||
first-class option; a whole-city service area matches every district in that city when search lands (b7).
|
||||
- **Duplicate service area → `409`** (including a duplicate whole-city row), never a `500`. Surface it as
|
||||
"you already cover this".
|
||||
- **Single primary address** — the first address is primary automatically; setting another primary clears
|
||||
the previous one. There is always exactly one.
|
||||
- **`is_active` hides, never deletes** — a deactivated region simply drops out of the dropdowns.
|
||||
- **Address PII is encrypted at rest** and only ever returned to the owning customer. Coordinates and the
|
||||
`title` label are not PII.
|
||||
- **Tenancy** — service areas and addresses are strictly owner-scoped; another owner's id returns `404`.
|
||||
- **Refresh after `select_role`** still applies (nurse/customer scoping reads the role claim in the token).
|
||||
- **Routes are action-style** (`admin_geo/create_city`, `nurse_service_areas/add`,
|
||||
`customer_addresses/create`, …) — see the contract for the full list.
|
||||
|
||||
## What's mocked
|
||||
- **Geocoding (`IGeocoder` → 🟡).** `MockGeocoder` returns deterministic coordinates around the city
|
||||
centroid with no network call. A config switch (`Seams:Geocoding:ReturnNullCoordinates`) or a `NO_GEO`
|
||||
marker in the address text forces the null-coordinate path so the "saved without a map pin" UI state is
|
||||
testable. Real Neshan/Google geocoding is a drop-in registration swap (see mocks-registry).
|
||||
|
||||
## Schema / migration
|
||||
Migration **`20260702093332_GeographyAddressesServiceAreas`** (applies on startup): `geo.Provinces`,
|
||||
`geo.Cities`, `geo.Districts`, `geo.NurseServiceAreas`, `usr.CustomerAddresses`. Whole-city uniqueness is a
|
||||
**filtered-index pair** (`UNIQUE(nurse_id, city_id) WHERE district_id IS NULL AND deleted_at IS NULL` +
|
||||
`UNIQUE(nurse_id, city_id, district_id) WHERE district_id IS NOT NULL AND deleted_at IS NULL`); addresses
|
||||
carry a filtered `UNIQUE(customer_id) WHERE is_primary=1 AND deleted_at IS NULL`; coordinates are
|
||||
`decimal(9,6)`; address PII columns are encrypted. Seed: 31 provinces (Tehran id `1`), capital cities
|
||||
(Tehran city id `101`), Tehran districts `1001…1022`.
|
||||
|
||||
## Deferred to later phases (do not build against these yet)
|
||||
- **`nurse_search_index` fan-out** on service-area add/remove → **b7** (the add/remove handlers are the
|
||||
clean trigger point).
|
||||
- **GPS-radius / "nurses near me" map discovery** → not planned; coverage is named districts, full stop.
|
||||
- **EVV distance check** that *consumes* the address `latitude`/`longitude` → **b9** (this phase only
|
||||
*produces* the coordinates).
|
||||
- **Region bulk-import feed** (`IGeoDataImporter`) → deferred; the idempotent seed + admin CRUD is enough
|
||||
for MVP.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Backend phase 4 report — Geography, addresses & nurse service areas
|
||||
|
||||
## What was built
|
||||
- **New `geo` schema + 5 tables (one migration `GeographyAddressesServiceAreas`):** `Provinces` 1:N
|
||||
`Cities` 1:N `Districts` (reference hierarchy), `NurseServiceAreas` (nurse coverage), and
|
||||
`usr.CustomerAddresses` (identity-domain saved locations). Each has an `IEntityTypeConfiguration<T>`, a
|
||||
`deleted_at IS NULL` soft-delete filter, and audit-field wiring via the b0 interceptor.
|
||||
- **Idempotent seed (b1 `HasData` path):** all 31 Iranian provinces (Tehran first, deterministic
|
||||
`sort_order`), each province's capital city (covers the product's white-space targets — Tehran, Karaj,
|
||||
Mashhad, Isfahan, Shiraz, Tabriz, Ahvaz, Qom), and Tehran's 22 municipal مناطق. Fixed ids
|
||||
(city = `100 + provinceId`; Tehran city `101`; Tehran districts `1001…1022`).
|
||||
- **20 CQRS slices across 4 controllers:**
|
||||
- `GeoController` (public): `provinces`, `cities?province_id=`, `districts?city_id=`, `tree` — projected,
|
||||
cached, active-only with parent-active honoured.
|
||||
- `AdminGeoController` (dynamic-permission): create/update/set_active for province/city/district; every
|
||||
write invalidates the geo cache.
|
||||
- `NurseServiceAreasController` (nurse): `add` (whole-city or city+district), `remove/{id}`, `list`.
|
||||
- `CustomerAddressesController` (customer): `create`, `update/{id}`, `set_primary/{id}`, `delete/{id}`,
|
||||
`list`.
|
||||
- **New `IGeocoder` seam** (Application `Contracts/Common`; `MockGeocoder` in CrossCutting; DI in
|
||||
`AddCrossCuttingSeams`; config `Seams:Geocoding`). Address create/update sets coordinates from it.
|
||||
- **`409 Conflict`** added to the result envelope (`OperationResult.ConflictResult` / `IsConflict` /
|
||||
`BaseController` → 409) — used for duplicate service areas.
|
||||
- Per-domain repositories (`IGeoRepository`, `INurseServiceAreaRepository`, `ICustomerAddressRepository`)
|
||||
on `IUnitOfWork`; encrypted value converters for the address PII columns.
|
||||
|
||||
## What is now testable and exactly how (per phase §7)
|
||||
1. **Seed** — `GET api/v1/geo/provinces` → 31 (Tehran first); `…/geo/cities?province_id=1` includes Tehran
|
||||
(city 101); `…/geo/districts?city_id=101` → 22; `…/geo/districts?city_id=105` (Mashhad) → **empty**.
|
||||
2. **Cascading dropdown / tree** — the three lazy lookups or `GET api/v1/geo/tree` (one payload).
|
||||
3. **Admin toggle** — `POST api/v1/admin_geo/set_city_active/{id}` `{isActive:false}` → the city
|
||||
disappears from `geo/cities`; `{isActive:true}` → it returns (not deleted).
|
||||
4. **Nurse whole-city area** — `POST api/v1/nurse_service_areas/add {cityId:101}` → `isWholeCity:true`.
|
||||
5. **Duplicate rejected** — repeat the same add → **`409`**; add `{cityId:101, districtId:1001}` → ok;
|
||||
repeat → **`409`**.
|
||||
6. **Geocoded address** — `POST api/v1/customer_addresses/create {..., isPrimary:true}` → `latitude`/
|
||||
`longitude` populated; `list` shows it primary-first with the address decrypted for the owner.
|
||||
7. **Single primary** — a second `isPrimary:true` create (or `set_primary/{id}`) clears the previous;
|
||||
exactly one `isPrimary` row remains.
|
||||
8. **PII not leaked** — `address_line`/`postal_code`/recipient fields are encrypted at rest; only the
|
||||
owner's own read decrypts them.
|
||||
|
||||
Covered by tests: **+16 `Baya.Test.Api` integration** (Geo/AdminGeo/NurseServiceAreas/CustomerAddresses —
|
||||
happy path, 401, validation 400, 409 duplicate, single-primary, geocode, is_active hide/show) and **+12
|
||||
handler unit tests** (NSubstitute — duplicate→conflict, geocode wiring, single-primary clear, tenancy 404,
|
||||
role checks). Full suite: **103 pass**, `dotnet build Baya.sln` with 0 new code warnings.
|
||||
|
||||
## Decisions fixed here (recorded in product docs / CLAUDE.md)
|
||||
- **Whole-city (`district_id NULL`) uniqueness = a filtered-index pair** (not a plain unique index, which
|
||||
SQL Server would let duplicate NULLs through). Both filters also exclude soft-deleted rows so a removed
|
||||
area can be re-declared.
|
||||
- **`district_id NULL` is a meaningful "entire city"** coverage value, never "unset".
|
||||
- **Named districts, not GPS radii** — address lat/lng is only for the later EVV distance check.
|
||||
- **Single-primary address** = filtered unique index + clear-then-set in one transaction; first address is
|
||||
primary by default.
|
||||
- **Address PII columns** (`address_line`, `postal_code`, `recipient_name`, `recipient_phone`) encrypted
|
||||
through `IFieldEncryptor`.
|
||||
- **Action-style routes** kept (over the phase's resource-style sketch) for codebase + dynamic-permission
|
||||
consistency.
|
||||
|
||||
## Mocked + how to make it real
|
||||
- **`IGeocoder` → 🟡.** `MockGeocoder` = deterministic point around the city centroid (FNV-1a jitter), no
|
||||
network. Config `Seams:Geocoding:{ReturnNullCoordinates, LowConfidenceMarker, ResolvedConfidence}`; the
|
||||
`NO_GEO` marker or the switch forces null coordinates. **Make it real:** add a Neshan (or Google)
|
||||
geocoding client package, add `Seams:Geocoding:{ApiKey,BaseUrl}`, implement `IGeocoder.GeocodeAsync`
|
||||
mapping the vendor response to `(lat, lng, formatted_address, confidence)` (decimal coords), add
|
||||
rate-limit/retry, swap the registration in `AddCrossCuttingSeams` — handlers unchanged; test a known
|
||||
Tehran address resolves within expected bounds.
|
||||
|
||||
## Contract produced
|
||||
`dev/contracts/domains/geography-addresses.md` + refreshed `dev/contracts/openapi/swagger.v1.json` (20 new
|
||||
paths). This is what **f3-b4** consumes.
|
||||
|
||||
## Follow-ups for later phases
|
||||
- **b7:** wire the `nurse_search_index` fan-out into the (already-isolated) service-area add/remove trigger
|
||||
points.
|
||||
- **b9:** the EVV distance check that consumes `customer_addresses.latitude/longitude`.
|
||||
- **Region bulk-import feed** (`IGeoDataImporter`): deferred; the idempotent seed + admin CRUD suffices for
|
||||
MVP.
|
||||
@@ -27,7 +27,7 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢
|
||||
| `IIdentityKycProvider` | backend-phase-6 | National-ID + liveness — fake pass | _tbd_ | Finnotech/U-ID/Jibbit/Verify liveness+OCR | 🔴 |
|
||||
| `ICredentialVerifier` | backend-phase-6 | MoH/INO/criminal-record — manual/fake | _tbd_ | Manual admin today; API when a portal appears (`verification_method=api`) | 🔴 |
|
||||
| `IBankAccountOwnershipVerifier` | backend-phase-3 | استعلام شبا IBAN-owner ↔ national-id inquiry — `MockBankAccountOwnershipVerifier` (`Baya.Infrastructure.CrossCutting/Seams/`) returns a deterministic fake: every IBAN matches (`matched_national_id=true`, echoes a holder name + `MOCK-SHEBA-{sha}` vendor ref) except the configured mismatch IBAN which returns `false`; registered singleton in `AddCrossCuttingSeams`. No real bank/KYC call, no money moves | `Seams:BankOwnership:MismatchIban` (default `IR000000000000000000000000`), `Seams:BankOwnership:MatchedHolderName`, `Seams:BankOwnership:MismatchHolderName` | 1) pick a Finnotech / banking-bridge استعلام شبا provider, add its client package to `Directory.Packages.props`; 2) add `Seams:BankOwnership:{ApiKey,BaseUrl}` options; 3) implement `VerifyOwnershipAsync(iban, nurseNationalId)` against the real Sheba-owner inquiry, mapping to `OwnershipInquiryResult`; 4) persist the real `ownership_vendor_ref` (+ raw response if a column is added); 5) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 6) test match/mismatch + that the b13 first-payout gate honours `matched_national_id=true` | 🟡 |
|
||||
| `IGeocoder` | backend-phase-4 | Address→lat/lng — echo/static | _tbd_ | Neshan/Google geocoding | 🔴 |
|
||||
| `IGeocoder` | backend-phase-4 | Address→lat/lng — `MockGeocoder` (`Baya.Infrastructure.CrossCutting/Seams/`) returns deterministic `decimal` coordinates jittered (FNV-1a, ~±5 km) around the known city centroid (unknown city → Iran centroid) plus `formatted_address` + `confidence`; **no network call**. A global switch or a per-address marker forces the null-coordinate ("no map pin") path; registered singleton in `AddCrossCuttingSeams` | `Seams:Geocoding:ReturnNullCoordinates` (default `false`), `Seams:Geocoding:LowConfidenceMarker` (default `NO_GEO`), `Seams:Geocoding:ResolvedConfidence` (default `0.9`) | 1) pick Neshan (or Google) geocoding, add its client package to `Directory.Packages.props`; 2) add `Seams:Geocoding:{ApiKey,BaseUrl}` options; 3) implement `IGeocoder.GeocodeAsync(addressText, cityName, districtName?)` against it, mapping to `(lat, lng, formatted_address, confidence)` with `decimal` coords; 4) add rate-limit/retry; 5) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 6) test a known Tehran address resolves within expected bounds | 🟡 |
|
||||
| `IMoadianClient` | backend-phase-11 | سامانه مودیان e-invoice — leaves ref pending | _tbd_ | Real مودیان submission → 22-digit ref | 🔴 |
|
||||
| `IReviewModerationService` | backend-phase-14 | AI moderation — keyword/pass-through | _tbd_ | Real classifier/LLM endpoint | 🔴 |
|
||||
| `IFieldEncryptor` | backend-phase-0 | PII encryption — AES-256-CBC + HMAC hash from a local symmetric key (`SymmetricFieldEncryptor`, `Baya.Infrastructure.CrossCutting/Seams/`) | `Seams:FieldEncryption:Key`, `Seams:FieldEncryption:HashKey` | KMS / column encryption / Key Vault / HSM | 🟡 |
|
||||
|
||||
Reference in New Issue
Block a user