5.7 KiB
5.7 KiB
Backend phase 4 report — Geography, addresses & nurse service areas
What was built
- New
geoschema + 5 tables (one migrationGeographyAddressesServiceAreas):Provinces1:NCities1:NDistricts(reference hierarchy),NurseServiceAreas(nurse coverage), andusr.CustomerAddresses(identity-domain saved locations). Each has anIEntityTypeConfiguration<T>, adeleted_at IS NULLsoft-delete filter, and audit-field wiring via the b0 interceptor. - Idempotent seed (b1
HasDatapath): all 31 Iranian provinces (Tehran first, deterministicsort_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 city101; Tehran districts1001…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
IGeocoderseam (ApplicationContracts/Common;MockGeocoderin CrossCutting; DI inAddCrossCuttingSeams; configSeams:Geocoding). Address create/update sets coordinates from it. 409 Conflictadded to the result envelope (OperationResult.ConflictResult/IsConflict/BaseController→ 409) — used for duplicate service areas.- Per-domain repositories (
IGeoRepository,INurseServiceAreaRepository,ICustomerAddressRepository) onIUnitOfWork; encrypted value converters for the address PII columns.
What is now testable and exactly how (per phase §7)
- Seed —
GET api/v1/geo/provinces→ 31 (Tehran first);…/geo/cities?province_id=1includes Tehran (city 101);…/geo/districts?city_id=101→ 22;…/geo/districts?city_id=105(Mashhad) → empty. - Cascading dropdown / tree — the three lazy lookups or
GET api/v1/geo/tree(one payload). - Admin toggle —
POST api/v1/admin_geo/set_city_active/{id}{isActive:false}→ the city disappears fromgeo/cities;{isActive:true}→ it returns (not deleted). - Nurse whole-city area —
POST api/v1/nurse_service_areas/add {cityId:101}→isWholeCity:true. - Duplicate rejected — repeat the same add →
409; add{cityId:101, districtId:1001}→ ok; repeat →409. - Geocoded address —
POST api/v1/customer_addresses/create {..., isPrimary:true}→latitude/longitudepopulated;listshows it primary-first with the address decrypted for the owner. - Single primary — a second
isPrimary:truecreate (orset_primary/{id}) clears the previous; exactly oneisPrimaryrow remains. - 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 NULLis 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 throughIFieldEncryptor. - 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. ConfigSeams:Geocoding:{ReturnNullCoordinates, LowConfidenceMarker, ResolvedConfidence}; theNO_GEOmarker or the switch forces null coordinates. Make it real: add a Neshan (or Google) geocoding client package, addSeams:Geocoding:{ApiKey,BaseUrl}, implementIGeocoder.GeocodeAsyncmapping the vendor response to(lat, lng, formatted_address, confidence)(decimal coords), add rate-limit/retry, swap the registration inAddCrossCuttingSeams— 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_indexfan-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.