add build development phases
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
# 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`.
|
||||
Reference in New Issue
Block a user