Files
baya-monorepo/dev/shared-working-context/reports/backend-phase-4-report.md
T
hamid 82561c4cc6 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>
2026-07-02 16:06:12 +03:30

5.7 KiB

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. SeedGET 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 togglePOST 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 areaPOST 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 addressPOST 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 leakedaddress_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.