30 KiB
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_ordertoggles; 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=NULLmeaning the whole city); and let a customer save service addresses with a single enforced primary, encrypted at rest, and geocoded coordinates behind theIGeocoderseam. After this phase, catalog/search and booking have the geography they consume.Track: backend · Depends on: b3 (
nurse_profiles,customer_profiles), b0 (theIGeocoderhost,IFieldEncryptor, REST surface, audit/caching seams), b1 (the seed pattern + typed config accessor) · Unlocks: catalog/search (b5/b7), booking (b8); frontend f3-b4 Before you start, read../_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 built the role profile extensions offusers(nurse_profiles.id1:1 with a nurseuser;customer_profiles.id1:1 with a customeruser), pluspatientsandnurse_bank_accountsand their tenancy rules.nurse_service_areashangs offnurse_profiles;customer_addresseshangs offcustomer_profiles. Reuse those FKs — do not re-create the profiles.- The
IGeocoderhost — b0 reserved the registry row for the geocoding seam (it is introduced here, see §4). b0 also builtIFieldEncryptor(the encrypt/decrypt + deterministicHashused 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, andLoggingBehavior. - The seed + typed-config pattern — b1 established how reference/seed data is
loaded into the marketplace baseline (the first migration baseline, the seeding mechanism, the typed
cached
platform_configsaccessor). 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 sealedhandlers),OperationResult<T>, Mapster, FluentValidation,IUnitOfWork, soft-delete query filters, theServiceConfiguration/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.mdand../_shared/backend-conventions-checklist.md— especially Persistence (projection + pagination, oneIEntityTypeConfiguration<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— the canonical geo schema:provinces1:Ncities1:Ndistrictswithsort_order/is_active;districtsare optional (Tehran's 22 مناطق / neighborhoods elsewhere);nurse_service_areaswithdistrict_id=NULL= whole city andUNIQUE(nurse_id, city_id, district_id); named districts, not a GPS radius.product/data-model/01-identity-and-access.md— thecustomer_addressesdefinition (encrypted address + coordinates for EVV, filteredUNIQUE(customer_id) WHERE is_primary=1) and hownurse_service_areasrelates tonurse_profiles.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 makesdistrict_id=NULLsemantics load-bearing — get them right here.- The digests (authoritative per-domain detail): the Geography and Identity & Access →
customer_addresses/nurse_service_areassections ofdm_identity_geo_services_verif.md, and the Search & Matching → geo section ofbiz_catalog_search.md(in the run'sdigests/). - Code to mirror: b3's
nurse_profiles/customer_profilesconfigs and thecustomer/nursefeature command structure; b1's seeding mechanism + the typed cached config accessor + the first migration baseline it created (you add one migration on top); b0'sIFieldEncryptorusage on PII columns, theICacheServiceGetOrCreateAsyncpattern, and anyServiceConfiguration/seam registration. - Contract conventions:
../../contracts/conventions/api-conventions.md(envelope, snake_case routes, pagination shape) andmoney-and-types.md(not money here, but the id/type conventions — ids areBIGINT, coordinates aredecimal, neverfloat). - Prior handoffs:
dev/shared-working-context/backend/handoff/after-backend-phase-3.md,…-1.md,…-0.md, andreports/mocks-registry.md(theIGeocoderrow 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 NULLdistrict_idrows. 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 indexUNIQUE(nurse_id, city_id) WHERE district_id IS NULLplusUNIQUE(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 viaIFieldEncryptor),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 bybooking_requests/bookings(b8/b9 — DEFERRED here, just don't preclude it).
Soft-delete & audit: every table declares the global
deleted_at IS NULLquery filter and inherits audit-field stamping from b0's interceptor — handlers never setCreatedAt/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_orderby 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 — 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). Allsealed : BaseController, injectISender, returnbase.OperationResult(...), snake_case[controller]/[action]routes,CancellationTokenthreaded. - Validators (FluentValidation): non-empty
name_fa/name_enon geo create/update; validprovince_id/city_idparents;AddNurseServiceArea(city active, district-belongs-to-city when provided);CreateAddress/UpdateAddress(city active, non-emptyaddress_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_indexfan-out on service-area add/remove — DEFERRED to b7. 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§(c)). Geography is named districts, full stop. Do not add a radius/distance model. - Region bulk-import feed (
IGeoDataImporteragainst 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. 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
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 = NULLis a MEANINGFUL value ("entire city"), not missing data. It must participate correctly in theUNIQUE(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 filteris_active=1at 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=1index 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 throughIFieldEncryptorat 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 thetitlelabel are not PII and may stay plaintext.) - Tenancy.
nurse_idon a service area andcustomer_idon an address come fromICurrentUser, never from the request body; a nurse/customer can only read and mutate their own rows; cross-tenant access returns404, not403revealing 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
ICacheServiceand 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 unboundedToListAsync(), no entity hydration just to map.
6. Definition of Done
The shared definition-of-done.md, plus:
- The five tables (
provinces,cities,districts,nurse_service_areas,customer_addresses) exist via one migration, each with itsIEntityTypeConfiguration<T>, soft-delete query filter, and audit wiring; thenurse_service_areaswhole-city-aware uniqueness and thecustomer_addressesfilteredUNIQUE(customer_id) WHERE is_primary=1are real DB constraints; coordinates aredecimal(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), withGeoController,AdminGeoController,NurseServiceAreasController,CustomerAddressesController. IGeocoderintroduced (Application interface, Infrastructure mock, DI registration via aServiceConfiguration/extension, config-selected). Noif (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_activefiltering. ≥1WebApplicationFactoryintegration test per controller (happy path, 401, validation 400).dotnet build Baya.slnzero new warnings;dotnet test Baya.slngreen. - The Project map in
server/CLAUDE.mdreflectsFeatures/Geography/**,Features/ServiceAreas/**,Features/Addresses/**and the newGeographydomain folder + theIGeocoderseam; the contractdev/contracts/domains/geography-addresses.mdis written and theswagger.jsonsnapshot republished.
7. How to test (what a human can verify after this phase)
- Seed ran — start the API →
GET api/v1/geo/provincesreturns the 31 provinces ordered bysort_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). - Cascading dropdown —
provinces→ pick one →cities?province_id=→ pick one →districts?city_id=each returns onlyis_active=1rows insort_order; (orGET api/v1/geo/treereturns the whole active tree in one payload). - Admin toggle —
POST api/v1/admin_geo/cities/{id}/set_active(deactivate) → that city disappears fromGET api/v1/geo/citiesfor its province (and its districts disappear) without being deleted; re-activate → it returns. Confirmsis_activehides, not deletes. - 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_areasshows it flagged "whole city". - 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. - Create a geocoded address — as a customer,
POST api/v1/customer_addresses { title, city_id, address_line, is_primary:true }→201withlatitude/longitudepopulated (from theIGeocodermock);GET api/v1/customer_addressesshows it primary-first with the address decrypted for the owner. - Single-primary enforced — create a second address with
is_primary:true(orPOST …/{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 oneis_primary=1row exists. - PII not leaked — confirm
address_lineis 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(add the newGeographydomain + theFeatures/Geography|ServiceAreas|Addresses/**areas and theIGeocoderseam); 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 inproduct/data-model/02-geography.mdor01-identity-and-access.md(regenerate the HTML view perproduct/CLAUDE.md). Don't invent rules. - Contract to write:
dev/contracts/domains/geography-addresses.md(per../../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,NurseServiceAreaDtowith the "whole city" flag,CustomerAddressDtowith masked PII anddecimalcoordinates); thedistrict_id=NULL⇒ whole-city semantics spelled out; auth/rate-limit/tenancy notes; the409duplicate-area and single-primary side-effects. Republish theswagger.jsonsnapshot per../../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 behindIGeocoder; thedistrict_id=NULLsemantics the frontend must honour). Append tobackend/STATUS.md, writedev/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 updatedev/shared-working-context/reports/mocks-registry.md(flip theIGeocoderrow → 🟡). - Memory: save a
projectmemory 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 theIGeocoderseam — with a one-line pointer inMEMORY.md.