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:
hamid
2026-07-02 16:06:12 +03:30
parent 39a979b1a7
commit 82561c4cc6
113 changed files with 9817 additions and 5 deletions
@@ -0,0 +1,125 @@
# Contract — Geography, addresses & nurse service areas (backend phase b4)
> The province→city→district reference hierarchy (public cascading dropdowns + admin curation), a nurse's
> declared service areas, and a customer's saved (encrypted, geocoded) addresses. Assumes
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema:
> [`../openapi/swagger.v1.json`](../openapi/README.md) (refreshed for b4).
**Status:** live as of backend-phase-b4 · **Frontend consumer:** frontend-phase-f3-b4
> **Routing note.** Routes are **action-style** (`[controller]/[action]`, snake_cased) to match the
> codebase convention and the dynamic-permission key scheme — e.g. create-a-city is
> `POST api/v1/admin_geo/create_city`, not `POST api/v1/admin_geo/cities`. Ids for edit/toggle/remove come
> from the **route**, never the body. All responses use the standard `{ succeeded, statusCode, data }`
> envelope; `data` shapes are below.
## Key semantics (read first)
- **`districtId = null` ⇒ whole city.** For a nurse service area it is a real coverage choice ("I cover
the entire city"), not missing data. Search (b7) treats a whole-city row as matching every district in
that city. A city with **no districts** (e.g. Mashhad) is whole-city-only and its district list is a
valid **empty** result.
- **Coverage is named districts, never a GPS radius.** Address coordinates exist only for the later EVV
distance check (b9), not for matching.
- **`is_active` hides, never deletes.** A deactivated province/city/district disappears from the public
dropdowns (parent-active is honoured on the join) without deleting the region or orphaning rows.
- **Exactly one primary address** per customer; the first address is primary by default.
- **Address PII is encrypted at rest** and decrypted only in the owner's own read.
## Public geo lookups — `GeoController` (no auth)
### `GET api/v1/geo/provinces`
- Active provinces, ordered by `sortOrder`. Cached. `data`: `ProvinceDto[]`.
### `GET api/v1/geo/cities?province_id={id}`
- Active cities under an (active) province, ordered. Empty if the province is inactive/absent. `data`: `CityDto[]`.
### `GET api/v1/geo/districts?city_id={id}`
- Active districts under an (active) city, ordered. **Empty list is valid** (whole-city-only city). `data`: `DistrictDto[]`.
### `GET api/v1/geo/tree`
- The full active province→city→district tree in one cached payload. `data`: `ProvinceTreeDto[]`.
## Admin geo curation — `AdminGeoController` (admin / dynamic-permission)
Every write **invalidates the geo cache**. Names required (`nameFa`/`nameEn`); `409` is not used here.
| Route | Body | Result |
| --- | --- | --- |
| `POST admin_geo/create_province` | `{ nameFa, nameEn, sortOrder }` | `ProvinceDto` |
| `POST admin_geo/update_province/{id}` | `{ nameFa, nameEn, sortOrder }` | `ProvinceDto` |
| `POST admin_geo/set_province_active/{id}` | `{ isActive }` | `true` |
| `POST admin_geo/create_city` | `{ provinceId, nameFa, nameEn, sortOrder }` | `CityDto` |
| `POST admin_geo/update_city/{id}` | `{ nameFa, nameEn, sortOrder }` | `CityDto` |
| `POST admin_geo/set_city_active/{id}` | `{ isActive }` | `true` |
| `POST admin_geo/create_district` | `{ cityId, nameFa, nameEn, sortOrder }` | `DistrictDto` |
| `POST admin_geo/update_district/{id}` | `{ nameFa, nameEn, sortOrder }` | `DistrictDto` |
| `POST admin_geo/set_district_active/{id}` | `{ isActive }` | `true` |
- **Failure cases:** `400` invalid names / unknown parent (`provinceId`/`cityId`); `401` unauthenticated;
`403` non-admin; `404` unknown id on update/toggle.
## Nurse service areas — `NurseServiceAreasController` (authenticated; nurse-scoped in handler)
### `POST api/v1/nurse_service_areas/add`
- **Body:** `{ cityId, districtId? }` — omit/`null` `districtId` = whole city.
- **`data`:** `NurseServiceAreaDto`.
- **Failure cases:** `400` invalid/inactive city, or district not in the (active) city; `401`
unauthenticated; `403` caller is not a nurse; **`409`** the nurse already declared this exact coverage
(including a duplicate **whole-city** row) — never a `500`.
- **Tenancy/side effects:** `nurseId` from the caller, never the body. (Deferred: this is the trigger
point for the b7 `nurse_search_index` fan-out.)
### `DELETE api/v1/nurse_service_areas/remove/{id}`
- Soft-removes the nurse's own area. `data`: `true`. `404` if not owned/absent (existence not leaked).
### `GET api/v1/nurse_service_areas/list?page=&page_size=`
- The nurse's own areas, whole-city first, paginated. `data`: `PagedResult<NurseServiceAreaDto>`.
## Customer addresses — `CustomerAddressesController` (authenticated; customer-scoped in handler)
### `POST api/v1/customer_addresses/create`
- **Body:** `{ title, cityId, districtId?, addressLine, postalCode?, recipientName?, recipientPhone?, isPrimary? }`.
- **`data`:** `CustomerAddressDto` (with `latitude`/`longitude` set from the geocoder, or `null` if
unresolved).
- **Behaviour:** encrypts the PII columns; geocodes via `IGeocoder`; the first address (or `isPrimary:true`)
becomes the single primary (prior primary cleared in the same unit of work). A thin customer profile is
auto-provisioned on first address if needed.
- **Failure cases:** `400` empty `title`/`addressLine`, invalid/inactive city, district not in the city,
bad postal-code format; `401`; `403` caller is not a customer.
### `POST api/v1/customer_addresses/update/{id}`
- Edits an owned address; re-geocodes when `addressLine`/`cityId`/`districtId` changes; re-encrypts PII.
`data`: `CustomerAddressDto`. `404` if not owned.
### `POST api/v1/customer_addresses/set_primary/{id}`
- Atomically makes the owned address primary and clears the previous. `data`: `true`. `404` if not owned.
### `DELETE api/v1/customer_addresses/delete/{id}`
- Soft-deletes the owned address. `data`: `true`. `404` if not owned.
### `GET api/v1/customer_addresses/list?page=&page_size=`
- The customer's own addresses, **primary first**, paginated, with PII **decrypted for the owner**. `data`:
`PagedResult<CustomerAddressDto>`.
## Shared shapes
- `ProvinceDto`: `id` (long), `nameFa` (string), `nameEn` (string), `sortOrder` (int).
- `CityDto`: `id`, `provinceId`, `nameFa`, `nameEn`, `sortOrder`.
- `DistrictDto`: `id`, `cityId`, `nameFa`, `nameEn`, `sortOrder`.
- `CityTreeDto`: `id`, `nameFa`, `nameEn`, `sortOrder`, `districts` (`DistrictDto[]`).
- `ProvinceTreeDto`: `id`, `nameFa`, `nameEn`, `sortOrder`, `cities` (`CityTreeDto[]`).
- `NurseServiceAreaDto`: `id`, `cityId`, `cityNameFa`, `cityNameEn`, `districtId` (long?, null = whole
city), `districtNameFa` (null when whole city), `districtNameEn` (null when whole city), `isWholeCity`
(bool), `isActive` (bool).
- `CustomerAddressDto`: `id`, `title`, `cityId`, `cityNameFa`, `cityNameEn`, `districtId` (long?),
`districtNameFa` (null?), `districtNameEn` (null?), `addressLine` (decrypted, owner-only), `postalCode`
(decrypted, owner-only, null?), `latitude` (decimal?, null when ungeocoded), `longitude` (decimal?),
`isPrimary` (bool), `recipientName` (decrypted, null?), `recipientPhone` (decrypted, null?).
## Seed (available on a fresh DB)
31 provinces (Tehran first, `sortOrder` deterministic), each province's capital city (covers Tehran,
Karaj, Mashhad, Isfahan, Shiraz, Tabriz, Ahvaz, Qom), and Tehran's 22 مناطق. Tehran province id `1`,
Tehran city id `101`, Tehran districts `1001…1022`; other cities have no districts at seed time.
## Changelog
- b4 — initial contract: public geo lookups, admin geo CRUD + set_active, nurse service areas, customer
addresses; `IGeocoder` seam; `409` conflict added to the envelope.
File diff suppressed because it is too large Load Diff
@@ -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 | 🟡 |
@@ -54,6 +54,8 @@
<p><strong>Role:</strong> The person receiving care, <strong>separate from the payer</strong>. <strong>Why:</strong> the payer (adult child, spouse) is usually not the patient (elderly parent, newborn, post-surgical adult); one customer registers many patients, each with its own clinical baseline and longitudinal record. Unchanged: <code>id</code>, <code>customer_id</code>, <code>display_name</code>, <code>first_name</code>, <code>last_name</code>, <code>birth_date</code>, <code>gender</code>, <code>blood_type</code>, <code>initial_medical_notes</code> (enc), <code>is_active</code>, timestamps. <strong>Relations:</strong> N:1 → <code>customer_profiles</code>; 1:N → <code>booking_requests</code>, <code>patient_care_records</code>. <strong>Tenancy invariant:</strong> a <code>booking_request.patient_id</code> must belong to the same <code>customer_id</code>.</p>
<h3 id="customer_addresses-core"><code>customer_addresses</code> [CORE] <a class="anchor" href="#customer_addresses-core" aria-hidden="true">#</a></h3>
<p><strong>Role:</strong> Saved service locations; the encrypted address + coordinates for EVV distance checks. <strong>Why coordinates:</strong> EVV check-in compares the nurse's GPS against the booking address within tolerance. Unchanged fields, plus: <strong>filtered <code>UNIQUE(customer_id) WHERE is_primary=1</code></strong> so exactly one primary exists (prevents ambiguous default). <strong>Relations:</strong> N:1 → <code>customer_profiles</code>, <code>cities</code>, <code>districts</code>; referenced by <code>booking_requests</code>/<code>bookings</code>.</p>
<blockquote><p><strong>As-built (backend-phase-4):</strong> <code>usr.CustomerAddresses</code><code>address_line</code>, <code>postal_code</code>, <code>recipient_name</code> and <code>recipient_phone</code> are <strong>encrypted at rest</strong> via <code>IFieldEncryptor</code> and returned only in the owning customer's own read (decrypted); <code>title</code> and coordinates (<code>decimal(9,6)</code>, nullable until geocoded) stay plaintext. The <strong>first</strong> address is primary by default; <code>set_primary</code> thereafter clears the prior primary and sets the new one in one transaction, with the filtered <code>UNIQUE(customer_id) WHERE is_primary=1 AND deleted_at IS NULL</code> index as the DB backstop. Coordinates are produced by the mocked <strong><code>IGeocoder</code></strong> seam on create/update (nullable when the address can't be resolved) and are consumed by the EVV distance check only later (b9), never for coverage matching.</p>
</blockquote>
<h3 id="nurse_bank_accounts-core"><code>nurse_bank_accounts</code> [CORE] <a class="anchor" href="#nurse_bank_accounts-core" aria-hidden="true">#</a></h3>
<p><strong>Role:</strong> Payout destination (IBAN/Sheba). <strong>Why hardened:</strong> the IBAN is the single place real money leaves the platform — the original "admin eyeballs the IBAN" check is exactly the forgeable, money-mule-risk link the research warns about.</p>
<div class="table-wrap"><table><thead><tr><th>Field</th><th>Type</th><th>Notes</th></tr></thead><tbody>
@@ -55,6 +55,15 @@ Fields unchanged from baseline: `id`, `email` (enc, nullable), `phone` (enc, uni
### `customer_addresses` [CORE]
**Role:** Saved service locations; the encrypted address + coordinates for EVV distance checks. **Why coordinates:** EVV check-in compares the nurse's GPS against the booking address within tolerance. Unchanged fields, plus: **filtered `UNIQUE(customer_id) WHERE is_primary=1`** so exactly one primary exists (prevents ambiguous default). **Relations:** N:1 → `customer_profiles`, `cities`, `districts`; referenced by `booking_requests`/`bookings`.
> **As-built (backend-phase-4):** `usr.CustomerAddresses` — `address_line`, `postal_code`,
> `recipient_name` and `recipient_phone` are **encrypted at rest** via `IFieldEncryptor` and returned only
> in the owning customer's own read (decrypted); `title` and coordinates (`decimal(9,6)`, nullable until
> geocoded) stay plaintext. The **first** address is primary by default; `set_primary` thereafter clears
> the prior primary and sets the new one in one transaction, with the filtered
> `UNIQUE(customer_id) WHERE is_primary=1 AND deleted_at IS NULL` index as the DB backstop. Coordinates are
> produced by the mocked **`IGeocoder`** seam on create/update (nullable when the address can't be
> resolved) and are consumed by the EVV distance check only later (b9), never for coverage matching.
### `nurse_bank_accounts` [CORE]
**Role:** Payout destination (IBAN/Sheba). **Why hardened:** the IBAN is the single place real money leaves the platform — the original "admin eyeballs the IBAN" check is exactly the forgeable, money-mule-risk link the research warns about.
+2
View File
@@ -21,6 +21,8 @@
<p><strong>Role:</strong> The geo hierarchy backing service areas, addresses, and search. <strong>Why a table, not a static list:</strong> new cities/districts launch without a deploy, and <code>sort_order</code>/<code>is_active</code> drive ordered, toggleable dropdowns. <code>districts</code> map to Tehran's 22 municipal districts or major neighborhoods elsewhere; they are <strong>optional</strong> (a nurse can cover a whole city). Fields unchanged. <strong>Relations:</strong> <code>provinces</code> 1:N <code>cities</code> 1:N <code>districts</code>; referenced by <code>customer_addresses</code> and <code>nurse_service_areas</code>.</p>
<h3 id="nurse_service_areas-core"><code>nurse_service_areas</code> [CORE] <a class="anchor" href="#nurse_service_areas-core" aria-hidden="true">#</a></h3>
<p><strong>Role:</strong> Where a nurse will travel. A row with <code>district_id = NULL</code> means the entire city. <strong>Why a join table (not a radius):</strong> Iranian nurses think in named districts, not GPS radii; this also drives the geographic filter in search cheaply. Unchanged, with <code>UNIQUE(nurse_id, city_id, district_id)</code>. <strong>Relations:</strong> N:1 → <code>nurse_profiles</code>, <code>cities</code>, <code>districts</code>.</p>
<blockquote><p><strong>As-built (backend-phase-4):</strong> the geo hierarchy lives in a <strong><code>geo</code> schema</strong> (<code>Provinces</code>/<code>Cities</code>/ <code>Districts</code>/<code>NurseServiceAreas</code>), seeded via <code>HasData</code> with all 31 provinces, each province's capital city, and Tehran's 22 مناطق. Because SQL Server treats NULLs as distinct in a unique index, the whole-city (<code>district_id = NULL</code>) uniqueness is enforced with a <strong>filtered-index pair</strong><code>UNIQUE(nurse_id, city_id) WHERE district_id IS NULL AND deleted_at IS NULL</code> <strong>plus</strong> <code>UNIQUE(nurse_id, city_id, district_id) WHERE district_id IS NOT NULL AND deleted_at IS NULL</code> — so both a duplicate whole-city row and a duplicate city+district row are rejected (surfaced as <code>409</code>), while a soft-removed area can be re-declared. Public lookups are cached and filter <code>is_active</code> at every level (a deactivated parent hides its children); geocoding is behind the mocked <strong><code>IGeocoder</code></strong> seam.</p>
</blockquote>
<a class="back-to-top" href="#">↑ Back to top</a>
</div></main>
</div>
+10
View File
@@ -7,3 +7,13 @@
### `nurse_service_areas` [CORE]
**Role:** Where a nurse will travel. A row with `district_id = NULL` means the entire city. **Why a join table (not a radius):** Iranian nurses think in named districts, not GPS radii; this also drives the geographic filter in search cheaply. Unchanged, with `UNIQUE(nurse_id, city_id, district_id)`. **Relations:** N:1 → `nurse_profiles`, `cities`, `districts`.
> **As-built (backend-phase-4):** the geo hierarchy lives in a **`geo` schema** (`Provinces`/`Cities`/
> `Districts`/`NurseServiceAreas`), seeded via `HasData` with all 31 provinces, each province's capital
> city, and Tehran's 22 مناطق. Because SQL Server treats NULLs as distinct in a unique index, the
> whole-city (`district_id = NULL`) uniqueness is enforced with a **filtered-index pair**
> `UNIQUE(nurse_id, city_id) WHERE district_id IS NULL AND deleted_at IS NULL` **plus**
> `UNIQUE(nurse_id, city_id, district_id) WHERE district_id IS NOT NULL AND deleted_at IS NULL` — so both a
> duplicate whole-city row and a duplicate city+district row are rejected (surfaced as `409`), while a
> soft-removed area can be re-declared. Public lookups are cached and filter `is_active` at every level
> (a deactivated parent hides its children); geocoding is behind the mocked **`IGeocoder`** seam.
+28 -4
View File
@@ -81,15 +81,15 @@ projects/assemblies, Clean-Architecture layers, and cross-layer dependencies.
```
src/
├── Core/
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker)
│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + the platform-signal facade contracts + Contracts/Persistence per-domain repositories on IUnitOfWork), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly)
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker)
│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + the platform-signal facade contracts + Contracts/Persistence per-domain repositories on IUnitOfWork), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly)
├── Infrastructure/
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service)
│ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/)
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier) + AddCrossCuttingSeams
│ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net
├── API/
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications), appsettings*.json
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses), appsettings*.json
│ ├── Baya.WebFramework BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting)
│ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models (User only)
├── Shared/Baya.SharedKernel Extensions + validation base
@@ -106,7 +106,7 @@ Application reference Infrastructure or the API — this is a hard rule.
**Cross-cutting seams.** Application defines mock-able external dependencies as interfaces in
`Contracts/Common/` (`IDateTimeProvider`, `IFieldEncryptor`, `ICacheService`, `IObjectStorage`,
`INotificationDispatcher`, plus `ICurrentUser`). Their in-memory/local mock implementations live in
`INotificationDispatcher`, `IGeocoder`, plus `ICurrentUser`). Their in-memory/local mock implementations live in
`Baya.Infrastructure.CrossCutting/Seams/` and are registered by `AddCrossCuttingSeams(configuration)`
(config section `Seams`); `ICurrentUser` is registered in the Identity layer. Swapping a mock for a
real provider is a registration change — handlers depend only on the contract. Audit fields are
@@ -144,6 +144,30 @@ every `AbstractValidator<T>` in the Application assembly as `IValidator<T>` so t
`ValidateCommandBehavior` (and the `ModelStateValidationAttribute` controller filter) actually run —
route-supplied ids (e.g. `patients/update/{id}`) must therefore **not** be validated in the body command.
**Geography, addresses & nurse service areas (backend-phase-4).** A new **`geo` schema** holds the
`Provinces` 1:N `Cities` 1:N `Districts` reference hierarchy (tables, not code lists — new regions launch
by admin insert; `is_active`/`sort_order` drive ordered, toggleable dropdowns) plus `NurseServiceAreas`
(where a nurse travels). `usr.CustomerAddresses` (identity-domain) holds saved service locations. Seeded
via `HasData` (b1 path): 31 provinces + their capital cities (covers the white-space targets) + Tehran's 22
مناطق. Features under `Baya.Application/Features/{Geography|ServiceAreas|Addresses}/`; configs in
`Persistence/Configuration/{GeographyConfig|IdentityConfig}/`; per-domain repos (`IGeoRepository`,
`INurseServiceAreaRepository`, `ICustomerAddressRepository`) on `IUnitOfWork`. Load-bearing rules:
- **`district_id = NULL` means "entire city"** — a real coverage choice, not missing data. Whole-city
uniqueness is enforced with a **filtered-index pair** (`UNIQUE(nurse_id, city_id) WHERE district_id IS
NULL …` + `UNIQUE(nurse_id, city_id, district_id) WHERE district_id IS NOT NULL …`, both `AND deleted_at
IS NULL`), because SQL Server treats NULLs as distinct. A duplicate area returns **409** (`OperationResult.ConflictResult` → new `IsConflict` → `BaseController` 409 mapping).
- **Coverage is named districts, not GPS radii.** Address lat/lng exists only for the later EVV distance
check (b9); it is never used for coverage matching.
- **Single primary address** per customer via filtered `UNIQUE(customer_id) WHERE is_primary=1 AND
deleted_at IS NULL` + clear-then-set in one transaction; the first address is primary by default.
- **Address PII** (`address_line`, `postal_code`, recipient name/phone) is encrypted at rest through
`IFieldEncryptor` (converters in `ApplicationDbContext`); decrypted only in the owner's own read.
- **`IGeocoder`** (new seam, `Contracts/Common`; mock `MockGeocoder` in CrossCutting, config
`Seams:Geocoding`) turns a typed address into deterministic `decimal` coordinates with no network call;
a config switch / `NO_GEO` marker forces the null-coordinate path.
- **Reference reads are cached** through `ICacheService` behind a generation-token key scheme (`GeoCache`);
any admin geo write bumps the token, invalidating the whole geo cache namespace at once.
**Keeping the Project map current.** When a change touches the architecture — adds, removes, or
renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer
dependency — you **must** update this Project map (and the dependency rule above, if affected) in the
@@ -0,0 +1,73 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Geography.Commands.CreateCity;
using Baya.Application.Features.Geography.Commands.CreateDistrict;
using Baya.Application.Features.Geography.Commands.CreateProvince;
using Baya.Application.Features.Geography.Commands.SetCityActive;
using Baya.Application.Features.Geography.Commands.SetDistrictActive;
using Baya.Application.Features.Geography.Commands.SetProvinceActive;
using Baya.Application.Features.Geography.Commands.UpdateCity;
using Baya.Application.Features.Geography.Commands.UpdateDistrict;
using Baya.Application.Features.Geography.Commands.UpdateProvince;
using Baya.Application.Models.Geography;
using Baya.Infrastructure.Identity.Identity.PermissionManager;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1;
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Authorize(ConstantPolicies.DynamicPermission)]
[Display(Description = "Admin: curate the geo hierarchy (create/edit + activate/deactivate; no delete)")]
public sealed class AdminGeoController(ISender sender) : BaseController
{
[HttpPost("[action]")]
[ProducesOkApiResponseType<ProvinceDto>]
public async Task<IActionResult> CreateProvince(CreateProvinceCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType<ProvinceDto>]
public async Task<IActionResult> UpdateProvince(long id, UpdateProvinceCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType]
public async Task<IActionResult> SetProvinceActive(long id, SetProvinceActiveCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
[HttpPost("[action]")]
[ProducesOkApiResponseType<CityDto>]
public async Task<IActionResult> CreateCity(CreateCityCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType<CityDto>]
public async Task<IActionResult> UpdateCity(long id, UpdateCityCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType]
public async Task<IActionResult> SetCityActive(long id, SetCityActiveCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
[HttpPost("[action]")]
[ProducesOkApiResponseType<DistrictDto>]
public async Task<IActionResult> CreateDistrict(CreateDistrictCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType<DistrictDto>]
public async Task<IActionResult> UpdateDistrict(long id, UpdateDistrictCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType]
public async Task<IActionResult> SetDistrictActive(long id, SetDistrictActiveCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
}
@@ -0,0 +1,49 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Addresses.Commands.CreateAddress;
using Baya.Application.Features.Addresses.Commands.DeleteAddress;
using Baya.Application.Features.Addresses.Commands.SetPrimaryAddress;
using Baya.Application.Features.Addresses.Commands.UpdateAddress;
using Baya.Application.Features.Addresses.Queries.ListMyAddresses;
using Baya.Application.Models.Addresses;
using Baya.Application.Models.Common;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1;
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Authorize]
[Display(Description = "The signed-in customer's saved service addresses")]
public sealed class CustomerAddressesController(ISender sender) : BaseController
{
[HttpPost("[action]")]
[ProducesOkApiResponseType<CustomerAddressDto>]
public async Task<IActionResult> Create(CreateAddressCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType<CustomerAddressDto>]
public async Task<IActionResult> Update(long id, UpdateAddressCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType]
public async Task<IActionResult> SetPrimary(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new SetPrimaryAddressCommand(id), cancellationToken));
[HttpDelete("[action]/{id}")]
[ProducesOkApiResponseType]
public async Task<IActionResult> Delete(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new DeleteAddressCommand(id), cancellationToken));
[HttpGet("[action]")]
[ProducesOkApiResponseType<PagedResult<CustomerAddressDto>>]
public async Task<IActionResult> List([FromQuery] ListMyAddressesQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
}
@@ -0,0 +1,40 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Geography.Queries.GetGeoTree;
using Baya.Application.Features.Geography.Queries.ListCities;
using Baya.Application.Features.Geography.Queries.ListDistricts;
using Baya.Application.Features.Geography.Queries.ListProvinces;
using Baya.Application.Models.Geography;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1;
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Display(Description = "Public geo lookups: the province → city → district cascading dropdowns")]
public sealed class GeoController(ISender sender) : BaseController
{
[HttpGet("[action]")]
[ProducesOkApiResponseType<IReadOnlyList<ProvinceDto>>]
public async Task<IActionResult> Provinces(CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new ListProvincesQuery(), cancellationToken));
[HttpGet("[action]")]
[ProducesOkApiResponseType<IReadOnlyList<CityDto>>]
public async Task<IActionResult> Cities([FromQuery(Name = "province_id")] long provinceId, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new ListCitiesQuery(provinceId), cancellationToken));
[HttpGet("[action]")]
[ProducesOkApiResponseType<IReadOnlyList<DistrictDto>>]
public async Task<IActionResult> Districts([FromQuery(Name = "city_id")] long cityId, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new ListDistrictsQuery(cityId), cancellationToken));
[HttpGet("[action]")]
[ProducesOkApiResponseType<IReadOnlyList<ProvinceTreeDto>>]
public async Task<IActionResult> Tree(CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetGeoTreeQuery(), cancellationToken));
}
@@ -0,0 +1,37 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.ServiceAreas.Commands.AddNurseServiceArea;
using Baya.Application.Features.ServiceAreas.Commands.RemoveNurseServiceArea;
using Baya.Application.Features.ServiceAreas.Queries.ListMyServiceAreas;
using Baya.Application.Models.Common;
using Baya.Application.Models.Geography;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1;
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Authorize]
[Display(Description = "The signed-in nurse's declared service areas (where they will travel)")]
public sealed class NurseServiceAreasController(ISender sender) : BaseController
{
[HttpPost("[action]")]
[ProducesOkApiResponseType<NurseServiceAreaDto>]
public async Task<IActionResult> Add(AddNurseServiceAreaCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpDelete("[action]/{id}")]
[ProducesOkApiResponseType]
public async Task<IActionResult> Remove(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new RemoveNurseServiceAreaCommand(id), cancellationToken));
[HttpGet("[action]")]
[ProducesOkApiResponseType<PagedResult<NurseServiceAreaDto>>]
public async Task<IActionResult> List([FromQuery] ListMyServiceAreasQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
}
@@ -18,6 +18,11 @@
},
"ObjectStorage": {
"RootPath": ""
},
"Geocoding": {
"ReturnNullCoordinates": false,
"LowConfidenceMarker": "NO_GEO",
"ResolvedConfidence": 0.9
}
},
"AllowedHosts": "*",
@@ -18,6 +18,11 @@
},
"ObjectStorage": {
"RootPath": ""
},
"Geocoding": {
"ReturnNullCoordinates": false,
"LowConfidenceMarker": "NO_GEO",
"ResolvedConfidence": 0.9
}
},
"AllowedHosts": "*",
@@ -46,6 +46,10 @@ public class BaseController : ControllerBase
return new JsonResult(new ApiResult(false, ApiResultStatusCode.Forbidden, FirstErrorMessage(result)))
{ StatusCode = StatusCodes.Status403Forbidden };
if (result.IsConflict)
return new JsonResult(new ApiResult(false, ApiResultStatusCode.Conflict, FirstErrorMessage(result)))
{ StatusCode = StatusCodes.Status409Conflict };
AddErrors(result);
var badRequestErrors = new ValidationProblemDetails(ModelState);
@@ -0,0 +1,36 @@
#nullable enable
namespace Baya.Application.Contracts.Common;
/// <summary>
/// Seam for turning a typed postal address into geographic coordinates. The mock derives a deterministic
/// point around the city centroid with no network call; the real implementation swaps to a Neshan/Google
/// geocoding client behind the same contract. Coordinates are <see cref="decimal"/> (never float) so the
/// EVV distance check (b9) that later consumes them is exact.
/// </summary>
public interface IGeocoder
{
/// <summary>
/// Resolves the free-text <paramref name="addressText"/> (within the named <paramref name="cityName"/>
/// and optional <paramref name="districtName"/>) to coordinates. A low-confidence / unresolvable
/// address yields a result with <see cref="GeocodeResult.Latitude"/>/<see cref="GeocodeResult.Longitude"/>
/// left <c>null</c> — the address is still saved, just without a map pin.
/// </summary>
ValueTask<GeocodeResult> GeocodeAsync(
string addressText,
string cityName,
string? districtName,
CancellationToken cancellationToken = default);
}
/// <summary>
/// The outcome of a geocoding attempt. <see cref="Latitude"/>/<see cref="Longitude"/> are <c>null</c> when
/// the provider could not resolve the address with enough confidence.
/// </summary>
public sealed record GeocodeResult(
decimal? Latitude,
decimal? Longitude,
string FormattedAddress,
double Confidence)
{
public bool HasCoordinates => Latitude is not null && Longitude is not null;
}
@@ -0,0 +1,31 @@
#nullable enable
using Baya.Application.Models.Addresses;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Identity;
namespace Baya.Application.Contracts.Persistence;
public interface ICustomerAddressRepository
{
Task AddAsync(CustomerAddress address, CancellationToken cancellationToken);
/// <summary>Tracked, tenancy-scoped lookup — returns the address only if it belongs to
/// <paramref name="customerId"/>, else null.</summary>
Task<CustomerAddress?> GetOwnedAsync(long id, long customerId, CancellationToken cancellationToken);
/// <summary>Whether the customer already has at least one address (the first becomes primary).</summary>
Task<bool> HasAnyAsync(long customerId, CancellationToken cancellationToken);
/// <summary>Clears the current primary (if any) other than <paramref name="addressId"/> for the
/// customer, in a single transaction, so the filtered <c>UNIQUE(customer_id) WHERE is_primary=1</c>
/// index never trips. The caller has already verified tenancy and set the new primary.</summary>
Task ClearOtherPrimaryAsync(long customerId, long addressId, CancellationToken cancellationToken);
/// <summary>Atomically makes <paramref name="addressId"/> primary and clears the prior primary
/// (clear-then-set order). The address must already be owned by the customer.</summary>
Task SetPrimaryAsync(long customerId, long addressId, CancellationToken cancellationToken);
/// <summary>No-tracking, paginated projection of the customer's own addresses (primary first), with the
/// PII decrypted for the owner.</summary>
Task<PagedResult<CustomerAddressDto>> ListAsync(long customerId, int page, int pageSize, CancellationToken cancellationToken);
}
@@ -0,0 +1,41 @@
#nullable enable
using Baya.Application.Models.Geography;
using Baya.Domain.Entities.Geography;
namespace Baya.Application.Contracts.Persistence;
/// <summary>
/// The province/city/district reference hierarchy: public active-only lookups for the cascading dropdowns
/// plus tracked getters/adds for the admin CRUD. Reads are projected + no-tracking; callers cache them.
/// </summary>
public interface IGeoRepository
{
Task<IReadOnlyList<ProvinceDto>> ListActiveProvincesAsync(CancellationToken cancellationToken);
/// <summary>Active cities under an active province, ordered — empty if the province is inactive/absent.</summary>
Task<IReadOnlyList<CityDto>> ListActiveCitiesAsync(long provinceId, CancellationToken cancellationToken);
/// <summary>Active districts under an active city, ordered — empty is valid (whole-city-only city).</summary>
Task<IReadOnlyList<DistrictDto>> ListActiveDistrictsAsync(long cityId, CancellationToken cancellationToken);
/// <summary>The full active province→city→district tree in one payload for the dropdown.</summary>
Task<IReadOnlyList<ProvinceTreeDto>> GetActiveTreeAsync(CancellationToken cancellationToken);
// Admin: tracked lookups (include inactive, exclude soft-deleted) for edit/toggle.
Task<Province?> GetProvinceAsync(long id, CancellationToken cancellationToken);
Task<City?> GetCityAsync(long id, CancellationToken cancellationToken);
Task<District?> GetDistrictAsync(long id, CancellationToken cancellationToken);
Task AddProvinceAsync(Province province, CancellationToken cancellationToken);
Task AddCityAsync(City city, CancellationToken cancellationToken);
Task AddDistrictAsync(District district, CancellationToken cancellationToken);
Task<bool> ProvinceExistsAsync(long provinceId, CancellationToken cancellationToken);
/// <summary>True when the city exists, is active, and its province is active.</summary>
Task<bool> IsCityActiveAsync(long cityId, CancellationToken cancellationToken);
/// <summary>True when the district exists, is active, belongs to <paramref name="cityId"/>, and that
/// city (and its province) are active.</summary>
Task<bool> IsDistrictInActiveCityAsync(long districtId, long cityId, CancellationToken cancellationToken);
}
@@ -0,0 +1,22 @@
#nullable enable
using Baya.Application.Models.Geography;
using Baya.Domain.Entities.Geography;
namespace Baya.Application.Contracts.Persistence;
public interface INurseServiceAreaRepository
{
Task AddAsync(NurseServiceArea area, CancellationToken cancellationToken);
/// <summary>Tracked, tenancy-scoped lookup — returns the area only if it belongs to
/// <paramref name="nurseId"/>, else null.</summary>
Task<NurseServiceArea?> GetOwnedAsync(long id, long nurseId, CancellationToken cancellationToken);
/// <summary>Whether the nurse already declared this exact coverage (a NULL <paramref name="districtId"/>
/// is the whole-city row) — the clean 409 guard ahead of the filtered unique index backstop.</summary>
Task<bool> DuplicateExistsAsync(long nurseId, long cityId, long? districtId, CancellationToken cancellationToken);
/// <summary>No-tracking, paginated projection of the nurse's own areas with city/district names and the
/// "whole city" flag; whole-city rows first, then by id.</summary>
Task<Models.Common.PagedResult<NurseServiceAreaDto>> ListAsync(long nurseId, int page, int pageSize, CancellationToken cancellationToken);
}
@@ -9,6 +9,9 @@ public interface IUnitOfWork
public ICustomerProfileRepository CustomerProfileRepository { get; }
public IPatientRepository PatientRepository { get; }
public INurseBankAccountRepository NurseBankAccountRepository { get; }
public IGeoRepository GeoRepository { get; }
public INurseServiceAreaRepository NurseServiceAreaRepository { get; }
public ICustomerAddressRepository CustomerAddressRepository { get; }
Task CommitAsync();
ValueTask RollBackAsync();
}
@@ -0,0 +1,100 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Addresses;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Geography;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.Addresses.Commands.CreateAddress;
internal sealed class CreateAddressCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IGeocoder geocoder)
: IRequestHandler<CreateAddressCommand, OperationResult<CustomerAddressDto>>
{
public async ValueTask<OperationResult<CustomerAddressDto>> Handle(CreateAddressCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<CustomerAddressDto>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
return OperationResult<CustomerAddressDto>.ForbiddenResult("Only a customer can save an address.");
var city = await unitOfWork.GeoRepository.GetCityAsync(request.CityId, cancellationToken);
if (city is null || !await unitOfWork.GeoRepository.IsCityActiveAsync(request.CityId, cancellationToken))
return OperationResult<CustomerAddressDto>.FailureResult(nameof(request.CityId), "City not found or inactive.");
District? district = null;
if (request.DistrictId is { } districtId)
{
if (!await unitOfWork.GeoRepository.IsDistrictInActiveCityAsync(districtId, request.CityId, cancellationToken))
return OperationResult<CustomerAddressDto>.FailureResult(nameof(request.DistrictId), "District not found in this city, or inactive.");
district = await unitOfWork.GeoRepository.GetDistrictAsync(districtId, cancellationToken);
}
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
var isFirst = customerId is not { } existing || !await unitOfWork.CustomerAddressRepository.HasAnyAsync(existing, cancellationToken);
var isPrimary = request.IsPrimary || isFirst;
var geo = await geocoder.GeocodeAsync(request.AddressLine, city.NameEn, district?.NameEn, cancellationToken);
// Values are set as plaintext; the EF value converter encrypts the PII columns at rest.
var address = new CustomerAddress
{
CityId = request.CityId,
DistrictId = request.DistrictId,
Title = request.Title,
AddressLine = request.AddressLine,
PostalCode = request.PostalCode,
RecipientName = request.RecipientName,
RecipientPhone = request.RecipientPhone,
Latitude = geo.Latitude,
Longitude = geo.Longitude,
IsPrimary = isPrimary
};
if (customerId is { } cid)
{
address.CustomerId = cid;
if (isPrimary)
await unitOfWork.CustomerAddressRepository.ClearOtherPrimaryAsync(cid, 0, cancellationToken);
}
else
{
// First address before any customer-profile save — provision the thin payer row so the FK is
// fixed up on commit (mirrors the b3 patient auto-provision).
var profile = new CustomerProfile { UserId = userId };
await unitOfWork.CustomerProfileRepository.AddAsync(profile, cancellationToken);
address.Customer = profile;
}
await unitOfWork.CustomerAddressRepository.AddAsync(address, cancellationToken);
await unitOfWork.CommitAsync();
return OperationResult<CustomerAddressDto>.SuccessResult(Build(address, city, district));
}
private static CustomerAddressDto Build(CustomerAddress address, City city, District? district) =>
new(
address.Id,
address.Title,
city.Id,
city.NameFa,
city.NameEn,
address.DistrictId,
district?.NameFa,
district?.NameEn,
address.AddressLine,
address.PostalCode,
address.Latitude,
address.Longitude,
address.IsPrimary,
address.RecipientName,
address.RecipientPhone);
}
@@ -0,0 +1,18 @@
using FluentValidation;
namespace Baya.Application.Features.Addresses.Commands.CreateAddress;
public sealed class CreateAddressCommandValidator : AbstractValidator<CreateAddressCommand>
{
public CreateAddressCommandValidator()
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(100);
RuleFor(x => x.CityId).GreaterThan(0);
RuleFor(x => x.DistrictId).GreaterThan(0).When(x => x.DistrictId.HasValue);
RuleFor(x => x.AddressLine).NotEmpty().MaximumLength(1000);
RuleFor(x => x.PostalCode)
.Matches("^[0-9۰-۹]{10}$")
.When(x => !string.IsNullOrWhiteSpace(x.PostalCode))
.WithMessage("Postal code must be 10 digits.");
}
}
@@ -0,0 +1,20 @@
using Baya.Application.Models.Addresses;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Addresses.Commands.CreateAddress;
/// <summary>
/// Creates a saved address for the signed-in customer. The street line, postal code and recipient contact
/// are encrypted at rest; coordinates are set from the <c>IGeocoder</c> seam. The first address (or one
/// created with <c>IsPrimary=true</c>) becomes the single primary. Tenancy is from the caller.
/// </summary>
public record CreateAddressCommand(
string Title,
long CityId,
long? DistrictId,
string AddressLine,
string PostalCode,
string RecipientName,
string RecipientPhone,
bool IsPrimary) : IRequest<OperationResult<CustomerAddressDto>>;
@@ -0,0 +1,37 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.Addresses.Commands.DeleteAddress;
internal sealed class DeleteAddressCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IDateTimeProvider clock)
: IRequestHandler<DeleteAddressCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(DeleteAddressCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<bool>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
return OperationResult<bool>.ForbiddenResult("Only a customer can delete an address.");
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (customerId is not { } cid)
return OperationResult<bool>.NotFoundResult("Address not found.");
var address = await unitOfWork.CustomerAddressRepository.GetOwnedAsync(request.Id, cid, cancellationToken);
if (address is null)
return OperationResult<bool>.NotFoundResult("Address not found.");
address.DeletedAt = clock.UtcNow;
await unitOfWork.CommitAsync();
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,7 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Addresses.Commands.DeleteAddress;
/// <summary>Soft-deletes one of the signed-in customer's own addresses (tenancy-checked).</summary>
public record DeleteAddressCommand(long Id) : IRequest<OperationResult<bool>>;
@@ -0,0 +1,34 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.Addresses.Commands.SetPrimaryAddress;
internal sealed class SetPrimaryAddressCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
: IRequestHandler<SetPrimaryAddressCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(SetPrimaryAddressCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<bool>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
return OperationResult<bool>.ForbiddenResult("Only a customer can manage addresses.");
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (customerId is not { } cid)
return OperationResult<bool>.NotFoundResult("Address not found.");
var address = await unitOfWork.CustomerAddressRepository.GetOwnedAsync(request.Id, cid, cancellationToken);
if (address is null)
return OperationResult<bool>.NotFoundResult("Address not found.");
if (!address.IsPrimary)
await unitOfWork.CustomerAddressRepository.SetPrimaryAsync(cid, request.Id, cancellationToken);
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,8 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Addresses.Commands.SetPrimaryAddress;
/// <summary>Atomically makes one of the customer's own addresses primary and clears the previous one
/// (the single-primary invariant). Tenancy-checked.</summary>
public record SetPrimaryAddressCommand(long Id) : IRequest<OperationResult<bool>>;
@@ -0,0 +1,87 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Addresses;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Geography;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.Addresses.Commands.UpdateAddress;
internal sealed class UpdateAddressCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IGeocoder geocoder)
: IRequestHandler<UpdateAddressCommand, OperationResult<CustomerAddressDto>>
{
public async ValueTask<OperationResult<CustomerAddressDto>> Handle(UpdateAddressCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<CustomerAddressDto>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
return OperationResult<CustomerAddressDto>.ForbiddenResult("Only a customer can edit an address.");
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (customerId is not { } cid)
return OperationResult<CustomerAddressDto>.NotFoundResult("Address not found.");
var address = await unitOfWork.CustomerAddressRepository.GetOwnedAsync(request.Id, cid, cancellationToken);
if (address is null)
return OperationResult<CustomerAddressDto>.NotFoundResult("Address not found.");
var city = await unitOfWork.GeoRepository.GetCityAsync(request.CityId, cancellationToken);
if (city is null || !await unitOfWork.GeoRepository.IsCityActiveAsync(request.CityId, cancellationToken))
return OperationResult<CustomerAddressDto>.FailureResult(nameof(request.CityId), "City not found or inactive.");
District? district = null;
if (request.DistrictId is { } districtId)
{
if (!await unitOfWork.GeoRepository.IsDistrictInActiveCityAsync(districtId, request.CityId, cancellationToken))
return OperationResult<CustomerAddressDto>.FailureResult(nameof(request.DistrictId), "District not found in this city, or inactive.");
district = await unitOfWork.GeoRepository.GetDistrictAsync(districtId, cancellationToken);
}
var locationChanged =
address.CityId != request.CityId ||
address.DistrictId != request.DistrictId ||
!string.Equals(address.AddressLine, request.AddressLine, StringComparison.Ordinal);
address.Title = request.Title;
address.CityId = request.CityId;
address.DistrictId = request.DistrictId;
address.AddressLine = request.AddressLine;
address.PostalCode = request.PostalCode;
address.RecipientName = request.RecipientName;
address.RecipientPhone = request.RecipientPhone;
if (locationChanged)
{
var geo = await geocoder.GeocodeAsync(request.AddressLine, city.NameEn, district?.NameEn, cancellationToken);
address.Latitude = geo.Latitude;
address.Longitude = geo.Longitude;
}
await unitOfWork.CommitAsync();
return OperationResult<CustomerAddressDto>.SuccessResult(new CustomerAddressDto(
address.Id,
address.Title,
city.Id,
city.NameFa,
city.NameEn,
address.DistrictId,
district?.NameFa,
district?.NameEn,
address.AddressLine,
address.PostalCode,
address.Latitude,
address.Longitude,
address.IsPrimary,
address.RecipientName,
address.RecipientPhone));
}
}
@@ -0,0 +1,18 @@
using FluentValidation;
namespace Baya.Application.Features.Addresses.Commands.UpdateAddress;
public sealed class UpdateAddressCommandValidator : AbstractValidator<UpdateAddressCommand>
{
public UpdateAddressCommandValidator()
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(100);
RuleFor(x => x.CityId).GreaterThan(0);
RuleFor(x => x.DistrictId).GreaterThan(0).When(x => x.DistrictId.HasValue);
RuleFor(x => x.AddressLine).NotEmpty().MaximumLength(1000);
RuleFor(x => x.PostalCode)
.Matches("^[0-9۰-۹]{10}$")
.When(x => !string.IsNullOrWhiteSpace(x.PostalCode))
.WithMessage("Postal code must be 10 digits.");
}
}
@@ -0,0 +1,17 @@
using Baya.Application.Models.Addresses;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Addresses.Commands.UpdateAddress;
/// <summary>Edits an address the signed-in customer owns. <c>Id</c> comes from the route; PII is
/// re-encrypted and coordinates are re-geocoded when the street line/city/district changes.</summary>
public record UpdateAddressCommand(
long Id,
string Title,
long CityId,
long? DistrictId,
string AddressLine,
string PostalCode,
string RecipientName,
string RecipientPhone) : IRequest<OperationResult<CustomerAddressDto>>;
@@ -0,0 +1,33 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Addresses;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.Addresses.Queries.ListMyAddresses;
internal sealed class ListMyAddressesQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
: IRequestHandler<ListMyAddressesQuery, OperationResult<PagedResult<CustomerAddressDto>>>
{
public async ValueTask<OperationResult<PagedResult<CustomerAddressDto>>> Handle(ListMyAddressesQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<PagedResult<CustomerAddressDto>>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Customer) != true)
return OperationResult<PagedResult<CustomerAddressDto>>.ForbiddenResult("Only a customer can view addresses.");
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (customerId is not { } cid)
return OperationResult<PagedResult<CustomerAddressDto>>.SuccessResult(
new PagedResult<CustomerAddressDto>([], 0, page, pageSize));
var result = await unitOfWork.CustomerAddressRepository.ListAsync(cid, page, pageSize, cancellationToken);
return OperationResult<PagedResult<CustomerAddressDto>>.SuccessResult(result);
}
}
@@ -0,0 +1,10 @@
using Baya.Application.Models.Addresses;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Addresses.Queries.ListMyAddresses;
/// <summary>The signed-in customer's own addresses, primary first (tenancy-scoped, paginated). The PII is
/// decrypted for the owner.</summary>
public record ListMyAddressesQuery(int Page = 1, int PageSize = 50)
: IRequest<OperationResult<PagedResult<CustomerAddressDto>>>;
@@ -0,0 +1,34 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Geography;
using Baya.Domain.Entities.Geography;
using Mediator;
namespace Baya.Application.Features.Geography.Commands.CreateCity;
internal sealed class CreateCityCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
: IRequestHandler<CreateCityCommand, OperationResult<CityDto>>
{
public async ValueTask<OperationResult<CityDto>> Handle(CreateCityCommand request, CancellationToken cancellationToken)
{
if (!await unitOfWork.GeoRepository.ProvinceExistsAsync(request.ProvinceId, cancellationToken))
return OperationResult<CityDto>.FailureResult(nameof(request.ProvinceId), "Province not found.");
var city = new City
{
ProvinceId = request.ProvinceId,
NameFa = request.NameFa,
NameEn = request.NameEn,
SortOrder = request.SortOrder,
IsActive = true
};
await unitOfWork.GeoRepository.AddCityAsync(city, cancellationToken);
await unitOfWork.CommitAsync();
await GeoCache.InvalidateAsync(cache, cancellationToken);
return OperationResult<CityDto>.SuccessResult(
new CityDto(city.Id, city.ProvinceId, city.NameFa, city.NameEn, city.SortOrder));
}
}
@@ -0,0 +1,13 @@
using FluentValidation;
namespace Baya.Application.Features.Geography.Commands.CreateCity;
public sealed class CreateCityCommandValidator : AbstractValidator<CreateCityCommand>
{
public CreateCityCommandValidator()
{
RuleFor(x => x.ProvinceId).GreaterThan(0);
RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150);
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150);
}
}
@@ -0,0 +1,9 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Geography;
using Mediator;
namespace Baya.Application.Features.Geography.Commands.CreateCity;
/// <summary>Admin: create a city under a province. Invalidates the geo cache.</summary>
public record CreateCityCommand(long ProvinceId, string NameFa, string NameEn, int SortOrder)
: IRequest<OperationResult<CityDto>>;
@@ -0,0 +1,35 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Geography;
using Baya.Domain.Entities.Geography;
using Mediator;
namespace Baya.Application.Features.Geography.Commands.CreateDistrict;
internal sealed class CreateDistrictCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
: IRequestHandler<CreateDistrictCommand, OperationResult<DistrictDto>>
{
public async ValueTask<OperationResult<DistrictDto>> Handle(CreateDistrictCommand request, CancellationToken cancellationToken)
{
var city = await unitOfWork.GeoRepository.GetCityAsync(request.CityId, cancellationToken);
if (city is null)
return OperationResult<DistrictDto>.FailureResult(nameof(request.CityId), "City not found.");
var district = new District
{
CityId = request.CityId,
NameFa = request.NameFa,
NameEn = request.NameEn,
SortOrder = request.SortOrder,
IsActive = true
};
await unitOfWork.GeoRepository.AddDistrictAsync(district, cancellationToken);
await unitOfWork.CommitAsync();
await GeoCache.InvalidateAsync(cache, cancellationToken);
return OperationResult<DistrictDto>.SuccessResult(
new DistrictDto(district.Id, district.CityId, district.NameFa, district.NameEn, district.SortOrder));
}
}
@@ -0,0 +1,13 @@
using FluentValidation;
namespace Baya.Application.Features.Geography.Commands.CreateDistrict;
public sealed class CreateDistrictCommandValidator : AbstractValidator<CreateDistrictCommand>
{
public CreateDistrictCommandValidator()
{
RuleFor(x => x.CityId).GreaterThan(0);
RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150);
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150);
}
}
@@ -0,0 +1,9 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Geography;
using Mediator;
namespace Baya.Application.Features.Geography.Commands.CreateDistrict;
/// <summary>Admin: add a district under a city (e.g. a neighborhood outside Tehran). Invalidates cache.</summary>
public record CreateDistrictCommand(long CityId, string NameFa, string NameEn, int SortOrder)
: IRequest<OperationResult<DistrictDto>>;
@@ -0,0 +1,31 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Geography;
using Baya.Domain.Entities.Geography;
using Mediator;
namespace Baya.Application.Features.Geography.Commands.CreateProvince;
internal sealed class CreateProvinceCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
: IRequestHandler<CreateProvinceCommand, OperationResult<ProvinceDto>>
{
public async ValueTask<OperationResult<ProvinceDto>> Handle(CreateProvinceCommand request, CancellationToken cancellationToken)
{
var province = new Province
{
NameFa = request.NameFa,
NameEn = request.NameEn,
SortOrder = request.SortOrder,
IsActive = true
};
await unitOfWork.GeoRepository.AddProvinceAsync(province, cancellationToken);
await unitOfWork.CommitAsync();
await GeoCache.InvalidateAsync(cache, cancellationToken);
return OperationResult<ProvinceDto>.SuccessResult(
new ProvinceDto(province.Id, province.NameFa, province.NameEn, province.SortOrder));
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace Baya.Application.Features.Geography.Commands.CreateProvince;
public sealed class CreateProvinceCommandValidator : AbstractValidator<CreateProvinceCommand>
{
public CreateProvinceCommandValidator()
{
RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150);
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150);
}
}
@@ -0,0 +1,9 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Geography;
using Mediator;
namespace Baya.Application.Features.Geography.Commands.CreateProvince;
/// <summary>Admin: create a province. New provinces launch by insert — no deploy. Invalidates the geo cache.</summary>
public record CreateProvinceCommand(string NameFa, string NameEn, int SortOrder)
: IRequest<OperationResult<ProvinceDto>>;
@@ -0,0 +1,24 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Geography.Commands.SetCityActive;
internal sealed class SetCityActiveCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
: IRequestHandler<SetCityActiveCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(SetCityActiveCommand request, CancellationToken cancellationToken)
{
var city = await unitOfWork.GeoRepository.GetCityAsync(request.Id, cancellationToken);
if (city is null)
return OperationResult<bool>.NotFoundResult("City not found.");
city.IsActive = request.IsActive;
await unitOfWork.CommitAsync();
await GeoCache.InvalidateAsync(cache, cancellationToken);
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,8 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Geography.Commands.SetCityActive;
/// <summary>Admin: toggle a city's active flag (no delete). A deactivated city — and its districts —
/// disappear from the public dropdowns without being deleted. Invalidates cache.</summary>
public record SetCityActiveCommand(long Id, bool IsActive) : IRequest<OperationResult<bool>>;
@@ -0,0 +1,24 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Geography.Commands.SetDistrictActive;
internal sealed class SetDistrictActiveCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
: IRequestHandler<SetDistrictActiveCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(SetDistrictActiveCommand request, CancellationToken cancellationToken)
{
var district = await unitOfWork.GeoRepository.GetDistrictAsync(request.Id, cancellationToken);
if (district is null)
return OperationResult<bool>.NotFoundResult("District not found.");
district.IsActive = request.IsActive;
await unitOfWork.CommitAsync();
await GeoCache.InvalidateAsync(cache, cancellationToken);
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,7 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Geography.Commands.SetDistrictActive;
/// <summary>Admin: toggle a district's active flag (no delete). Invalidates cache.</summary>
public record SetDistrictActiveCommand(long Id, bool IsActive) : IRequest<OperationResult<bool>>;
@@ -0,0 +1,24 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Geography.Commands.SetProvinceActive;
internal sealed class SetProvinceActiveCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
: IRequestHandler<SetProvinceActiveCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(SetProvinceActiveCommand request, CancellationToken cancellationToken)
{
var province = await unitOfWork.GeoRepository.GetProvinceAsync(request.Id, cancellationToken);
if (province is null)
return OperationResult<bool>.NotFoundResult("Province not found.");
province.IsActive = request.IsActive;
await unitOfWork.CommitAsync();
await GeoCache.InvalidateAsync(cache, cancellationToken);
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,8 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Geography.Commands.SetProvinceActive;
/// <summary>Admin: toggle a province's active flag (no delete). Deactivating hides its cities/districts
/// from the public dropdowns via the active-join filter, without orphaning anything. Invalidates cache.</summary>
public record SetProvinceActiveCommand(long Id, bool IsActive) : IRequest<OperationResult<bool>>;
@@ -0,0 +1,28 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Geography;
using Mediator;
namespace Baya.Application.Features.Geography.Commands.UpdateCity;
internal sealed class UpdateCityCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
: IRequestHandler<UpdateCityCommand, OperationResult<CityDto>>
{
public async ValueTask<OperationResult<CityDto>> Handle(UpdateCityCommand request, CancellationToken cancellationToken)
{
var city = await unitOfWork.GeoRepository.GetCityAsync(request.Id, cancellationToken);
if (city is null)
return OperationResult<CityDto>.NotFoundResult("City not found.");
city.NameFa = request.NameFa;
city.NameEn = request.NameEn;
city.SortOrder = request.SortOrder;
await unitOfWork.CommitAsync();
await GeoCache.InvalidateAsync(cache, cancellationToken);
return OperationResult<CityDto>.SuccessResult(
new CityDto(city.Id, city.ProvinceId, city.NameFa, city.NameEn, city.SortOrder));
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace Baya.Application.Features.Geography.Commands.UpdateCity;
public sealed class UpdateCityCommandValidator : AbstractValidator<UpdateCityCommand>
{
public UpdateCityCommandValidator()
{
RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150);
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150);
}
}
@@ -0,0 +1,9 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Geography;
using Mediator;
namespace Baya.Application.Features.Geography.Commands.UpdateCity;
/// <summary>Admin: edit a city's names/sort order. <c>Id</c> comes from the route. Invalidates cache.</summary>
public record UpdateCityCommand(long Id, string NameFa, string NameEn, int SortOrder)
: IRequest<OperationResult<CityDto>>;
@@ -0,0 +1,28 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Geography;
using Mediator;
namespace Baya.Application.Features.Geography.Commands.UpdateDistrict;
internal sealed class UpdateDistrictCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
: IRequestHandler<UpdateDistrictCommand, OperationResult<DistrictDto>>
{
public async ValueTask<OperationResult<DistrictDto>> Handle(UpdateDistrictCommand request, CancellationToken cancellationToken)
{
var district = await unitOfWork.GeoRepository.GetDistrictAsync(request.Id, cancellationToken);
if (district is null)
return OperationResult<DistrictDto>.NotFoundResult("District not found.");
district.NameFa = request.NameFa;
district.NameEn = request.NameEn;
district.SortOrder = request.SortOrder;
await unitOfWork.CommitAsync();
await GeoCache.InvalidateAsync(cache, cancellationToken);
return OperationResult<DistrictDto>.SuccessResult(
new DistrictDto(district.Id, district.CityId, district.NameFa, district.NameEn, district.SortOrder));
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace Baya.Application.Features.Geography.Commands.UpdateDistrict;
public sealed class UpdateDistrictCommandValidator : AbstractValidator<UpdateDistrictCommand>
{
public UpdateDistrictCommandValidator()
{
RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150);
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150);
}
}
@@ -0,0 +1,9 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Geography;
using Mediator;
namespace Baya.Application.Features.Geography.Commands.UpdateDistrict;
/// <summary>Admin: edit a district's names/sort order. <c>Id</c> comes from the route. Invalidates cache.</summary>
public record UpdateDistrictCommand(long Id, string NameFa, string NameEn, int SortOrder)
: IRequest<OperationResult<DistrictDto>>;
@@ -0,0 +1,28 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Geography;
using Mediator;
namespace Baya.Application.Features.Geography.Commands.UpdateProvince;
internal sealed class UpdateProvinceCommandHandler(IUnitOfWork unitOfWork, ICacheService cache)
: IRequestHandler<UpdateProvinceCommand, OperationResult<ProvinceDto>>
{
public async ValueTask<OperationResult<ProvinceDto>> Handle(UpdateProvinceCommand request, CancellationToken cancellationToken)
{
var province = await unitOfWork.GeoRepository.GetProvinceAsync(request.Id, cancellationToken);
if (province is null)
return OperationResult<ProvinceDto>.NotFoundResult("Province not found.");
province.NameFa = request.NameFa;
province.NameEn = request.NameEn;
province.SortOrder = request.SortOrder;
await unitOfWork.CommitAsync();
await GeoCache.InvalidateAsync(cache, cancellationToken);
return OperationResult<ProvinceDto>.SuccessResult(
new ProvinceDto(province.Id, province.NameFa, province.NameEn, province.SortOrder));
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace Baya.Application.Features.Geography.Commands.UpdateProvince;
public sealed class UpdateProvinceCommandValidator : AbstractValidator<UpdateProvinceCommand>
{
public UpdateProvinceCommandValidator()
{
RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150);
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150);
}
}
@@ -0,0 +1,9 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Geography;
using Mediator;
namespace Baya.Application.Features.Geography.Commands.UpdateProvince;
/// <summary>Admin: edit a province's names/sort order. <c>Id</c> comes from the route. Invalidates cache.</summary>
public record UpdateProvinceCommand(long Id, string NameFa, string NameEn, int SortOrder)
: IRequest<OperationResult<ProvinceDto>>;
@@ -0,0 +1,31 @@
#nullable enable
using Baya.Application.Contracts.Common;
namespace Baya.Application.Features.Geography;
/// <summary>
/// Cache-key scheme for the read-heavy geo lookups. Every data key is namespaced by a generation token;
/// an admin write bumps the token, which orphans all prior geo entries in one move (they lapse by TTL).
/// This makes cascade invalidation trivial and correct — deactivating a province instantly hides its
/// cities/districts without having to enumerate and evict each child key.
/// </summary>
internal static class GeoCache
{
private const string VersionKey = "geo:version";
// Reference data changes rarely; a modest TTL bounds staleness even if a bump is ever missed.
public static readonly TimeSpan Ttl = TimeSpan.FromHours(1);
public static ValueTask<string> VersionAsync(ICacheService cache, CancellationToken cancellationToken)
=> cache.GetOrCreateAsync(VersionKey, _ => ValueTask.FromResult(NewToken()), null, cancellationToken);
public static ValueTask InvalidateAsync(ICacheService cache, CancellationToken cancellationToken)
=> cache.SetAsync(VersionKey, NewToken(), null, cancellationToken);
public static string ProvincesKey(string version) => $"geo:{version}:provinces";
public static string CitiesKey(string version, long provinceId) => $"geo:{version}:cities:{provinceId}";
public static string DistrictsKey(string version, long cityId) => $"geo:{version}:districts:{cityId}";
public static string TreeKey(string version) => $"geo:{version}:tree";
private static string NewToken() => Guid.NewGuid().ToString("N");
}
@@ -0,0 +1,24 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Geography;
using Mediator;
namespace Baya.Application.Features.Geography.Queries.GetGeoTree;
internal sealed class GetGeoTreeQueryHandler(IUnitOfWork unitOfWork, ICacheService cache)
: IRequestHandler<GetGeoTreeQuery, OperationResult<IReadOnlyList<ProvinceTreeDto>>>
{
public async ValueTask<OperationResult<IReadOnlyList<ProvinceTreeDto>>> Handle(GetGeoTreeQuery request, CancellationToken cancellationToken)
{
var version = await GeoCache.VersionAsync(cache, cancellationToken);
var tree = await cache.GetOrCreateAsync(
GeoCache.TreeKey(version),
async ct => await unitOfWork.GeoRepository.GetActiveTreeAsync(ct),
GeoCache.Ttl,
cancellationToken);
return OperationResult<IReadOnlyList<ProvinceTreeDto>>.SuccessResult(tree);
}
}
@@ -0,0 +1,9 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Geography;
using Mediator;
namespace Baya.Application.Features.Geography.Queries.GetGeoTree;
/// <summary>The full active province→city→district tree in one cached payload for a single-round-trip
/// cascading dropdown. Public.</summary>
public record GetGeoTreeQuery : IRequest<OperationResult<IReadOnlyList<ProvinceTreeDto>>>;
@@ -0,0 +1,24 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Geography;
using Mediator;
namespace Baya.Application.Features.Geography.Queries.ListCities;
internal sealed class ListCitiesQueryHandler(IUnitOfWork unitOfWork, ICacheService cache)
: IRequestHandler<ListCitiesQuery, OperationResult<IReadOnlyList<CityDto>>>
{
public async ValueTask<OperationResult<IReadOnlyList<CityDto>>> Handle(ListCitiesQuery request, CancellationToken cancellationToken)
{
var version = await GeoCache.VersionAsync(cache, cancellationToken);
var cities = await cache.GetOrCreateAsync(
GeoCache.CitiesKey(version, request.ProvinceId),
async ct => await unitOfWork.GeoRepository.ListActiveCitiesAsync(request.ProvinceId, ct),
GeoCache.Ttl,
cancellationToken);
return OperationResult<IReadOnlyList<CityDto>>.SuccessResult(cities);
}
}
@@ -0,0 +1,8 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Geography;
using Mediator;
namespace Baya.Application.Features.Geography.Queries.ListCities;
/// <summary>Active cities under a province, ordered. Public.</summary>
public record ListCitiesQuery(long ProvinceId) : IRequest<OperationResult<IReadOnlyList<CityDto>>>;
@@ -0,0 +1,24 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Geography;
using Mediator;
namespace Baya.Application.Features.Geography.Queries.ListDistricts;
internal sealed class ListDistrictsQueryHandler(IUnitOfWork unitOfWork, ICacheService cache)
: IRequestHandler<ListDistrictsQuery, OperationResult<IReadOnlyList<DistrictDto>>>
{
public async ValueTask<OperationResult<IReadOnlyList<DistrictDto>>> Handle(ListDistrictsQuery request, CancellationToken cancellationToken)
{
var version = await GeoCache.VersionAsync(cache, cancellationToken);
var districts = await cache.GetOrCreateAsync(
GeoCache.DistrictsKey(version, request.CityId),
async ct => await unitOfWork.GeoRepository.ListActiveDistrictsAsync(request.CityId, ct),
GeoCache.Ttl,
cancellationToken);
return OperationResult<IReadOnlyList<DistrictDto>>.SuccessResult(districts);
}
}
@@ -0,0 +1,9 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Geography;
using Mediator;
namespace Baya.Application.Features.Geography.Queries.ListDistricts;
/// <summary>Active districts under a city, ordered. An empty list is valid — the city is whole-city-only
/// and the caller selects whole-city coverage. Public.</summary>
public record ListDistrictsQuery(long CityId) : IRequest<OperationResult<IReadOnlyList<DistrictDto>>>;
@@ -0,0 +1,24 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Geography;
using Mediator;
namespace Baya.Application.Features.Geography.Queries.ListProvinces;
internal sealed class ListProvincesQueryHandler(IUnitOfWork unitOfWork, ICacheService cache)
: IRequestHandler<ListProvincesQuery, OperationResult<IReadOnlyList<ProvinceDto>>>
{
public async ValueTask<OperationResult<IReadOnlyList<ProvinceDto>>> Handle(ListProvincesQuery request, CancellationToken cancellationToken)
{
var version = await GeoCache.VersionAsync(cache, cancellationToken);
var provinces = await cache.GetOrCreateAsync(
GeoCache.ProvincesKey(version),
async ct => await unitOfWork.GeoRepository.ListActiveProvincesAsync(ct),
GeoCache.Ttl,
cancellationToken);
return OperationResult<IReadOnlyList<ProvinceDto>>.SuccessResult(provinces);
}
}
@@ -0,0 +1,8 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Geography;
using Mediator;
namespace Baya.Application.Features.Geography.Queries.ListProvinces;
/// <summary>Active provinces, ordered by sort order, for the top of the cascading dropdown. Public.</summary>
public record ListProvincesQuery : IRequest<OperationResult<IReadOnlyList<ProvinceDto>>>;
@@ -0,0 +1,72 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Geography;
using Baya.Domain.Entities.Geography;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.ServiceAreas.Commands.AddNurseServiceArea;
internal sealed class AddNurseServiceAreaCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
: IRequestHandler<AddNurseServiceAreaCommand, OperationResult<NurseServiceAreaDto>>
{
public async ValueTask<OperationResult<NurseServiceAreaDto>> Handle(AddNurseServiceAreaCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<NurseServiceAreaDto>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<NurseServiceAreaDto>.ForbiddenResult("Only a nurse can declare a service area.");
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (nurseId is not { } nid)
return OperationResult<NurseServiceAreaDto>.FailureResult("No nurse profile exists yet. Create your profile first.");
var city = await unitOfWork.GeoRepository.GetCityAsync(request.CityId, cancellationToken);
if (city is null || !await unitOfWork.GeoRepository.IsCityActiveAsync(request.CityId, cancellationToken))
return OperationResult<NurseServiceAreaDto>.FailureResult(nameof(request.CityId), "City not found or inactive.");
District? district = null;
if (request.DistrictId is { } districtId)
{
if (!await unitOfWork.GeoRepository.IsDistrictInActiveCityAsync(districtId, request.CityId, cancellationToken))
return OperationResult<NurseServiceAreaDto>.FailureResult(nameof(request.DistrictId), "District not found in this city, or inactive.");
district = await unitOfWork.GeoRepository.GetDistrictAsync(districtId, cancellationToken);
}
// Whole-city and city+district duplicates are both rejected — the pre-check returns a clean 409;
// the filtered unique-index pair is the DB backstop.
if (await unitOfWork.NurseServiceAreaRepository.DuplicateExistsAsync(nid, request.CityId, request.DistrictId, cancellationToken))
return OperationResult<NurseServiceAreaDto>.ConflictResult(
request.DistrictId is null
? "You already cover this whole city."
: "You already cover this district.");
var area = new NurseServiceArea
{
NurseId = nid,
CityId = request.CityId,
DistrictId = request.DistrictId,
IsActive = true
};
// DEFERRED (b7): this is the write that later fans out nurse_search_index rows. Keep it the single
// trigger point — do not build the index here.
await unitOfWork.NurseServiceAreaRepository.AddAsync(area, cancellationToken);
await unitOfWork.CommitAsync();
return OperationResult<NurseServiceAreaDto>.SuccessResult(new NurseServiceAreaDto(
area.Id,
city.Id,
city.NameFa,
city.NameEn,
area.DistrictId,
district?.NameFa,
district?.NameEn,
area.DistrictId is null,
area.IsActive));
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace Baya.Application.Features.ServiceAreas.Commands.AddNurseServiceArea;
public sealed class AddNurseServiceAreaCommandValidator : AbstractValidator<AddNurseServiceAreaCommand>
{
public AddNurseServiceAreaCommandValidator()
{
RuleFor(x => x.CityId).GreaterThan(0);
RuleFor(x => x.DistrictId).GreaterThan(0).When(x => x.DistrictId.HasValue);
}
}
@@ -0,0 +1,13 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Geography;
using Mediator;
namespace Baya.Application.Features.ServiceAreas.Commands.AddNurseServiceArea;
/// <summary>
/// The signed-in nurse declares coverage. Omitting <see cref="DistrictId"/> (null) means the <b>whole
/// city</b> — a deliberate coverage choice. The nurse is derived from the caller, never the body. A
/// duplicate (including a duplicate whole-city row) returns 409.
/// </summary>
public record AddNurseServiceAreaCommand(long CityId, long? DistrictId)
: IRequest<OperationResult<NurseServiceAreaDto>>;
@@ -0,0 +1,39 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.ServiceAreas.Commands.RemoveNurseServiceArea;
internal sealed class RemoveNurseServiceAreaCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IDateTimeProvider clock)
: IRequestHandler<RemoveNurseServiceAreaCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(RemoveNurseServiceAreaCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<bool>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<bool>.ForbiddenResult("Only a nurse can manage service areas.");
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (nurseId is not { } nid)
return OperationResult<bool>.NotFoundResult("Service area not found.");
// Tenancy: a non-owned/nonexistent id resolves to null → not-found (existence is not leaked).
var area = await unitOfWork.NurseServiceAreaRepository.GetOwnedAsync(request.Id, nid, cancellationToken);
if (area is null)
return OperationResult<bool>.NotFoundResult("Service area not found.");
// DEFERRED (b7): triggers nurse_search_index row removal — keep this the single trigger point.
area.DeletedAt = clock.UtcNow;
await unitOfWork.CommitAsync();
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,7 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.ServiceAreas.Commands.RemoveNurseServiceArea;
/// <summary>Soft-removes one of the signed-in nurse's own service areas (tenancy-checked).</summary>
public record RemoveNurseServiceAreaCommand(long Id) : IRequest<OperationResult<bool>>;
@@ -0,0 +1,33 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Geography;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.ServiceAreas.Queries.ListMyServiceAreas;
internal sealed class ListMyServiceAreasQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
: IRequestHandler<ListMyServiceAreasQuery, OperationResult<PagedResult<NurseServiceAreaDto>>>
{
public async ValueTask<OperationResult<PagedResult<NurseServiceAreaDto>>> Handle(ListMyServiceAreasQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<PagedResult<NurseServiceAreaDto>>.UnauthorizedResult("Not authenticated.");
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
return OperationResult<PagedResult<NurseServiceAreaDto>>.ForbiddenResult("Only a nurse can view service areas.");
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (nurseId is not { } nid)
return OperationResult<PagedResult<NurseServiceAreaDto>>.SuccessResult(
new PagedResult<NurseServiceAreaDto>([], 0, page, pageSize));
var result = await unitOfWork.NurseServiceAreaRepository.ListAsync(nid, page, pageSize, cancellationToken);
return OperationResult<PagedResult<NurseServiceAreaDto>>.SuccessResult(result);
}
}
@@ -0,0 +1,9 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Geography;
using Mediator;
namespace Baya.Application.Features.ServiceAreas.Queries.ListMyServiceAreas;
/// <summary>The signed-in nurse's own service areas (tenancy-scoped, paginated, whole-city first).</summary>
public record ListMyServiceAreasQuery(int Page = 1, int PageSize = 50)
: IRequest<OperationResult<PagedResult<NurseServiceAreaDto>>>;
@@ -0,0 +1,24 @@
namespace Baya.Application.Models.Addresses;
/// <summary>
/// A customer's saved address, returned only in the owner's own read path — the encrypted street address,
/// postal code and recipient contact are decrypted here for the owner (they are never surfaced to any
/// other actor in this phase). Coordinates are <see cref="decimal"/> and may be null when the address was
/// saved without a confident geocode.
/// </summary>
public record CustomerAddressDto(
long Id,
string Title,
long CityId,
string CityNameFa,
string CityNameEn,
long? DistrictId,
string DistrictNameFa,
string DistrictNameEn,
string AddressLine,
string PostalCode,
decimal? Latitude,
decimal? Longitude,
bool IsPrimary,
string RecipientName,
string RecipientPhone);
@@ -26,6 +26,9 @@ public enum ApiResultStatusCode
[Display(Name = "Authorization Error")]
Forbidden = 403,
[Display(Name = "Conflict")]
Conflict = 409,
[Display(Name = "Not Acceptable")]
NotAcceptable = 406,
@@ -26,6 +26,10 @@ public class OperationResult<TResult> : IOperationResult
/// <summary>Maps to HTTP 403 — e.g. self-assigning an internal admin role (backend-phase-2).</summary>
public bool IsForbidden { get; set; }
/// <summary>Maps to HTTP 409 — a uniqueness/state conflict, e.g. a duplicate nurse service area
/// (backend-phase-4). Distinct from a validation 400 so callers can react to the collision.</summary>
public bool IsConflict { get; set; }
public static OperationResult<TResult> SuccessResult(TResult result)
{
return new OperationResult<TResult> { Result = result, IsSuccess = true };
@@ -74,6 +78,15 @@ public class OperationResult<TResult> : IOperationResult
return operationResult;
}
public static OperationResult<TResult> ConflictResult(string message)
{
var operationResult = new OperationResult<TResult> { IsSuccess = false, IsConflict = true };
operationResult.ErrorMessages.Add(new("GeneralError", message));
return operationResult;
}
public void AddError(string propertyName, string message)
{
IsSuccess = false;
@@ -0,0 +1,4 @@
namespace Baya.Application.Models.Geography;
/// <summary>A city option under a province for the cascading dropdown.</summary>
public record CityDto(long Id, long ProvinceId, string NameFa, string NameEn, int SortOrder);
@@ -0,0 +1,9 @@
namespace Baya.Application.Models.Geography;
/// <summary>A city node in the geo tree, carrying its active districts (empty when the city has none).</summary>
public record CityTreeDto(
long Id,
string NameFa,
string NameEn,
int SortOrder,
IReadOnlyList<DistrictDto> Districts);
@@ -0,0 +1,5 @@
namespace Baya.Application.Models.Geography;
/// <summary>A district option under a city. An empty district list for a city is a valid result — the
/// caller then selects whole-city coverage.</summary>
public record DistrictDto(long Id, long CityId, string NameFa, string NameEn, int SortOrder);
@@ -0,0 +1,16 @@
namespace Baya.Application.Models.Geography;
/// <summary>
/// A nurse's declared coverage row. <see cref="IsWholeCity"/> is <c>true</c> (and the district fields are
/// null) when <c>district_id</c> is NULL — meaning the entire city, a deliberate coverage choice.
/// </summary>
public record NurseServiceAreaDto(
long Id,
long CityId,
string CityNameFa,
string CityNameEn,
long? DistrictId,
string DistrictNameFa,
string DistrictNameEn,
bool IsWholeCity,
bool IsActive);
@@ -0,0 +1,4 @@
namespace Baya.Application.Models.Geography;
/// <summary>A province option for the cascading dropdown (active provinces, ordered by sort order).</summary>
public record ProvinceDto(long Id, string NameFa, string NameEn, int SortOrder);
@@ -0,0 +1,10 @@
namespace Baya.Application.Models.Geography;
/// <summary>A province node in the geo tree — the full active province→city→district hierarchy served in
/// one cached payload for the cascading dropdown.</summary>
public record ProvinceTreeDto(
long Id,
string NameFa,
string NameEn,
int SortOrder,
IReadOnlyList<CityTreeDto> Cities);
@@ -0,0 +1,23 @@
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Geography;
/// <summary>
/// A city under a <see cref="Province"/> — the main address/search granularity. A nurse who declares a
/// city with no district covers the whole city; search intersects on cities and districts, never on a GPS
/// radius.
/// </summary>
public class City : BaseEntity<long>
{
public long ProvinceId { get; set; }
public Province Province { get; set; }
public string NameFa { get; set; }
public string NameEn { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public ICollection<District> Districts { get; set; }
}
@@ -0,0 +1,21 @@
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Geography;
/// <summary>
/// An optional subdivision of a <see cref="City"/> — Tehran's 22 municipal مناطق, or major neighborhoods
/// elsewhere. Optional by design: a city with no districts is a valid whole-city-only region, and adding
/// neighborhoods elsewhere is a later admin insert, never a deploy.
/// </summary>
public class District : BaseEntity<long>
{
public long CityId { get; set; }
public City City { get; set; }
public string NameFa { get; set; }
public string NameEn { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
}
@@ -0,0 +1,26 @@
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Geography;
/// <summary>
/// Where a nurse will travel — the membership row search later intersects with a customer's address.
/// A row with <see cref="DistrictId"/> == <c>null</c> is a <b>meaningful "entire city"</b> coverage
/// choice, not missing data; search treats it as matching every district in that city. Whole-city and
/// city+district duplicates are both rejected at the DB level (see the filtered-index pair in the EF
/// configuration) — a nurse cannot declare the same coverage twice.
/// </summary>
public class NurseServiceArea : BaseEntity<long>
{
public long NurseId { get; set; }
public long CityId { get; set; }
public City City { get; set; }
/// <summary>NULL = the entire city (a deliberate coverage choice, not a forgotten selection).</summary>
public long? DistrictId { get; set; }
public District District { get; set; }
public bool IsActive { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
}
@@ -0,0 +1,21 @@
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Geography;
/// <summary>
/// Top of the geo hierarchy (Iran's 31 استان). Stored as a table — not a static list — so a new region
/// launches with an admin insert, and <see cref="SortOrder"/>/<see cref="IsActive"/> drive ordered,
/// toggleable cascading dropdowns. Deactivated far more often than deleted, so a toggled-off province
/// vanishes from public dropdowns without orphaning the addresses/service areas under it.
/// </summary>
public class Province : BaseEntity<long>
{
public string NameFa { get; set; }
public string NameEn { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public ICollection<City> Cities { get; set; }
}
@@ -0,0 +1,44 @@
using Baya.Domain.Common;
using Baya.Domain.Entities.Geography;
namespace Baya.Domain.Entities.Identity;
/// <summary>
/// A customer's saved service location. The street address and recipient contact are encrypted PII at
/// rest (decrypted only in the owner's own read path); <see cref="Latitude"/>/<see cref="Longitude"/> are
/// produced by the <c>IGeocoder</c> seam and later consumed by the EVV distance check (b9). Exactly one
/// address per customer is primary — enforced in the handler and by a filtered unique index.
/// </summary>
public class CustomerAddress : BaseEntity<long>
{
public long CustomerId { get; set; }
public CustomerProfile Customer { get; set; }
public long CityId { get; set; }
public City City { get; set; }
public long? DistrictId { get; set; }
public District District { get; set; }
/// <summary>A label ("خانه"/"محل کار") — not PII, stored plaintext.</summary>
public string Title { get; set; }
/// <summary>Encrypted at rest.</summary>
public string AddressLine { get; set; }
/// <summary>Encrypted at rest.</summary>
public string PostalCode { get; set; }
public decimal? Latitude { get; set; }
public decimal? Longitude { get; set; }
public bool IsPrimary { get; set; }
/// <summary>Encrypted at rest.</summary>
public string RecipientName { get; set; }
/// <summary>Encrypted at rest.</summary>
public string RecipientPhone { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
}
@@ -0,0 +1,87 @@
#nullable enable
using System.Text;
using Baya.Application.Contracts.Common;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Deterministic, network-free <see cref="IGeocoder"/> — the mock seam. It resolves an address to a
/// stable point jittered around the known city centroid (an unknown city falls back to Iran's centroid),
/// so the same address always yields the same coordinates without any external call. A configured global
/// switch or a per-address marker forces the null-coordinate path so the "saved without a map pin" state
/// is testable. The real implementation swaps to a Neshan/Google geocoding client behind this contract.
/// </summary>
public sealed class MockGeocoder(IOptions<SeamOptions> options) : IGeocoder
{
private readonly GeocodingOptions _options = options.Value.Geocoding;
// A few real city centroids for plausible pins; everything else falls back to Iran's centroid.
private static readonly IReadOnlyDictionary<string, (decimal Lat, decimal Lng)> Centroids =
new Dictionary<string, (decimal, decimal)>(StringComparer.OrdinalIgnoreCase)
{
["Tehran"] = (35.6892m, 51.3890m),
["Karaj"] = (35.8400m, 50.9391m),
["Mashhad"] = (36.2605m, 59.6168m),
["Isfahan"] = (32.6539m, 51.6660m),
["Shiraz"] = (29.5918m, 52.5837m),
["Tabriz"] = (38.0800m, 46.2919m),
["Ahvaz"] = (31.3183m, 48.6706m),
["Qom"] = (34.6416m, 50.8746m),
};
private static readonly (decimal Lat, decimal Lng) IranCentroid = (32.4279m, 53.6880m);
public ValueTask<GeocodeResult> GeocodeAsync(
string addressText,
string cityName,
string? districtName,
CancellationToken cancellationToken = default)
{
var formatted = FormatAddress(addressText, cityName, districtName);
var unresolved =
_options.ReturnNullCoordinates ||
(!string.IsNullOrEmpty(_options.LowConfidenceMarker) &&
addressText is not null &&
addressText.Contains(_options.LowConfidenceMarker, StringComparison.OrdinalIgnoreCase));
if (unresolved)
return ValueTask.FromResult(new GeocodeResult(null, null, formatted, 0.2));
var centroid = Centroids.TryGetValue(cityName ?? string.Empty, out var c) ? c : IranCentroid;
// Deterministic ±~0.045° jitter (~5 km) derived from a stable FNV-1a hash of the full text —
// never string.GetHashCode(), which is randomized per process and would break test determinism.
var seed = StableHash($"{cityName}|{districtName}|{addressText}");
var latOffset = Offset(seed);
var lngOffset = Offset(seed >> 16 ^ seed);
var lat = decimal.Round(centroid.Lat + latOffset, 6);
var lng = decimal.Round(centroid.Lng + lngOffset, 6);
return ValueTask.FromResult(new GeocodeResult(lat, lng, formatted, _options.ResolvedConfidence));
}
private static string FormatAddress(string addressText, string cityName, string? districtName) =>
string.Join("، ", new[] { cityName, districtName, addressText }
.Where(part => !string.IsNullOrWhiteSpace(part)));
// Maps the low 16 bits of the seed to a signed offset in [-0.045, +0.045] degrees.
private static decimal Offset(uint seed) => ((seed & 0xFFFF) / 65535m - 0.5m) * 0.09m;
private static uint StableHash(string value)
{
const uint offsetBasis = 2166136261;
const uint prime = 16777619;
var hash = offsetBasis;
foreach (var b in Encoding.UTF8.GetBytes(value))
{
hash ^= b;
hash *= prime;
}
return hash;
}
}
@@ -11,6 +11,25 @@ public sealed class SeamOptions
public FieldEncryptionOptions FieldEncryption { get; set; } = new();
public ObjectStorageOptions ObjectStorage { get; set; } = new();
public BankOwnershipOptions BankOwnership { get; set; } = new();
public GeocodingOptions Geocoding { get; set; } = new();
}
/// <summary>
/// Tunes the mock <c>IGeocoder</c>. By default it resolves every address to a deterministic point around
/// the city centroid. Set <see cref="ReturnNullCoordinates"/> to force the unresolved path globally, or
/// embed <see cref="LowConfidenceMarker"/> in a single address to exercise the "saved without a map pin"
/// UI state per-request. The real vendor implementation ignores these.
/// </summary>
public sealed class GeocodingOptions
{
/// <summary>When true, every geocode returns null coordinates with low confidence.</summary>
public bool ReturnNullCoordinates { get; set; }
/// <summary>An address whose text contains this marker resolves to null coordinates (testability).</summary>
public string LowConfidenceMarker { get; set; } = "NO_GEO";
/// <summary>Confidence returned for a successfully resolved address.</summary>
public double ResolvedConfidence { get; set; } = 0.9;
}
/// <summary>
@@ -32,6 +32,10 @@ public static class ServiceCollectionExtension
// fake match; a real Finnotech/banking-bridge client replaces this registration only.
services.AddSingleton<IBankAccountOwnershipVerifier, MockBankAccountOwnershipVerifier>();
// Address geocoding (backend-phase-4). The mock derives deterministic coordinates around the city
// centroid with no network call; a real Neshan/Google geocoding client replaces this registration.
services.AddSingleton<IGeocoder, MockGeocoder>();
return services;
}
}
@@ -121,5 +121,15 @@ public class ApplicationDbContext: IdentityDbContext<User, Role, int, UserClaim,
builder.Property(a => a.AccountHolderName).HasConversion(encrypted);
builder.Property(a => a.Iban).HasConversion(encrypted);
});
// b4 address PII: street line, postal code and recipient contact are encrypted at rest through
// the same seam; the title label and coordinates are not PII and stay plaintext.
modelBuilder.Entity<CustomerAddress>(builder =>
{
builder.Property(a => a.AddressLine).HasConversion(encrypted);
builder.Property(a => a.PostalCode).HasConversion(encrypted);
builder.Property(a => a.RecipientName).HasConversion(encrypted);
builder.Property(a => a.RecipientPhone).HasConversion(encrypted);
});
}
}
@@ -0,0 +1,30 @@
using Baya.Domain.Entities.Geography;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.GeographyConfig;
internal sealed class CityConfig : IEntityTypeConfiguration<City>
{
public void Configure(EntityTypeBuilder<City> builder)
{
builder.ToTable("Cities", "geo");
builder.Property(c => c.NameFa).HasMaxLength(150).IsRequired();
builder.Property(c => c.NameEn).HasMaxLength(150).IsRequired();
builder.Property(c => c.SortOrder).HasDefaultValue(0);
builder.Property(c => c.IsActive).HasDefaultValue(true);
// Ordered cascading lookup: cities for a province in sort order.
builder.HasIndex(c => new { c.ProvinceId, c.SortOrder });
builder.HasOne(c => c.Province)
.WithMany(p => p.Cities)
.HasForeignKey(c => c.ProvinceId)
.IsRequired();
builder.HasQueryFilter(c => c.DeletedAt == null);
builder.HasData(GeographySeed.Cities());
}
}
@@ -0,0 +1,29 @@
using Baya.Domain.Entities.Geography;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.GeographyConfig;
internal sealed class DistrictConfig : IEntityTypeConfiguration<District>
{
public void Configure(EntityTypeBuilder<District> builder)
{
builder.ToTable("Districts", "geo");
builder.Property(d => d.NameFa).HasMaxLength(150).IsRequired();
builder.Property(d => d.NameEn).HasMaxLength(150).IsRequired();
builder.Property(d => d.SortOrder).HasDefaultValue(0);
builder.Property(d => d.IsActive).HasDefaultValue(true);
builder.HasIndex(d => new { d.CityId, d.SortOrder });
builder.HasOne(d => d.City)
.WithMany(c => c.Districts)
.HasForeignKey(d => d.CityId)
.IsRequired();
builder.HasQueryFilter(d => d.DeletedAt == null);
builder.HasData(GeographySeed.Districts());
}
}
@@ -0,0 +1,109 @@
namespace Baya.Infrastructure.Persistence.Configuration.GeographyConfig;
/// <summary>
/// The one-time province/city/district seed, loaded via <c>HasData</c> so the rows land with the
/// migration on a fresh DB (the b1 seeding path). Ids are fixed and deterministic — city id is
/// <c>100 + provinceId</c>, Tehran's districts are <c>1001…1022</c> — so re-running is idempotent and the
/// model snapshot stays stable. All 31 provinces get their capital city (which covers the product's
/// white-space targets — Tehran, Karaj, Mashhad, Isfahan, Shiraz, Tabriz, Ahvaz, Qom, all provincial
/// capitals); only Tehran gets districts at seed time. Adding neighborhoods elsewhere is a later admin
/// insert, never a deploy.
/// </summary>
internal static class GeographySeed
{
public const long TehranProvinceId = 1;
public const long TehranCityId = 101;
// (id, name_fa, name_en, capital_fa, capital_en). Tehran first, then by convention; sort_order = id.
private static readonly (long Id, string NameFa, string NameEn, string CapitalFa, string CapitalEn)[] ProvinceRows =
[
(1, "تهران", "Tehran", "تهران", "Tehran"),
(2, "البرز", "Alborz", "کرج", "Karaj"),
(3, "اصفهان", "Isfahan", "اصفهان", "Isfahan"),
(4, "فارس", "Fars", "شیراز", "Shiraz"),
(5, "خراسان رضوی", "Razavi Khorasan", "مشهد", "Mashhad"),
(6, "آذربایجان شرقی", "East Azerbaijan", "تبریز", "Tabriz"),
(7, "آذربایجان غربی", "West Azerbaijan", "ارومیه", "Urmia"),
(8, "خوزستان", "Khuzestan", "اهواز", "Ahvaz"),
(9, "قم", "Qom", "قم", "Qom"),
(10, "کرمان", "Kerman", "کرمان", "Kerman"),
(11, "گیلان", "Gilan", "رشت", "Rasht"),
(12, "مازندران", "Mazandaran", "ساری", "Sari"),
(13, "مرکزی", "Markazi", "اراک", "Arak"),
(14, "اردبیل", "Ardabil", "اردبیل", "Ardabil"),
(15, "قزوین", "Qazvin", "قزوین", "Qazvin"),
(16, "کرمانشاه", "Kermanshah", "کرمانشاه", "Kermanshah"),
(17, "خراسان شمالی", "North Khorasan", "بجنورد", "Bojnord"),
(18, "خراسان جنوبی", "South Khorasan", "بیرجند", "Birjand"),
(19, "همدان", "Hamadan", "همدان", "Hamadan"),
(20, "کردستان", "Kurdistan", "سنندج", "Sanandaj"),
(21, "لرستان", "Lorestan", "خرم‌آباد", "Khorramabad"),
(22, "گلستان", "Golestan", "گرگان", "Gorgan"),
(23, "هرمزگان", "Hormozgan", "بندرعباس", "Bandar Abbas"),
(24, "بوشهر", "Bushehr", "بوشهر", "Bushehr"),
(25, "زنجان", "Zanjan", "زنجان", "Zanjan"),
(26, "سمنان", "Semnan", "سمنان", "Semnan"),
(27, "یزد", "Yazd", "یزد", "Yazd"),
(28, "سیستان و بلوچستان", "Sistan and Baluchestan", "زاهدان", "Zahedan"),
(29, "چهارمحال و بختیاری", "Chaharmahal and Bakhtiari", "شهرکرد", "Shahrekord"),
(30, "کهگیلویه و بویراحمد", "Kohgiluyeh and Boyer-Ahmad", "یاسوج", "Yasuj"),
(31, "ایلام", "Ilam", "ایلام", "Ilam"),
];
// Tehran's 22 municipal مناطق, as Persian ordinals ("منطقه ۱" … "منطقه ۲۲").
private static readonly string[] PersianDigits = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"];
public static object[] Provinces()
{
var ts = SeedConstants.Timestamp;
return ProvinceRows
.Select(p => (object)new
{
p.Id,
p.NameFa,
p.NameEn,
SortOrder = (int)p.Id,
IsActive = true,
CreatedAt = ts
})
.ToArray();
}
public static object[] Cities()
{
var ts = SeedConstants.Timestamp;
return ProvinceRows
.Select(p => (object)new
{
Id = 100 + p.Id,
ProvinceId = p.Id,
NameFa = p.CapitalFa,
NameEn = p.CapitalEn,
SortOrder = 1,
IsActive = true,
CreatedAt = ts
})
.ToArray();
}
public static object[] Districts()
{
var ts = SeedConstants.Timestamp;
return Enumerable.Range(1, 22)
.Select(n => (object)new
{
Id = 1000L + n,
CityId = TehranCityId,
NameFa = $"منطقه {ToPersianNumber(n)}",
NameEn = $"District {n}",
SortOrder = n,
IsActive = true,
CreatedAt = ts
})
.ToArray();
}
private static string ToPersianNumber(int value) =>
string.Concat(value.ToString(System.Globalization.CultureInfo.InvariantCulture)
.Select(c => PersianDigits[c - '0']));
}
@@ -0,0 +1,48 @@
using Baya.Domain.Entities.Geography;
using Baya.Domain.Entities.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.GeographyConfig;
internal sealed class NurseServiceAreaConfig : IEntityTypeConfiguration<NurseServiceArea>
{
public void Configure(EntityTypeBuilder<NurseServiceArea> builder)
{
builder.ToTable("NurseServiceAreas", "geo");
builder.Property(a => a.IsActive).HasDefaultValue(true);
// UNIQUE(nurse_id, city_id, district_id) that correctly rejects a duplicate whole-city row.
// SQL Server treats NULLs as distinct, so a single unique index would wrongly allow two
// "whole city" rows for the same nurse+city. Split into a filtered pair: one enforces at most one
// whole-city row (district_id IS NULL), the other enforces uniqueness of city+district rows. Both
// exclude soft-deleted rows so a removed area can be re-declared.
builder.HasIndex(a => new { a.NurseId, a.CityId })
.IsUnique()
.HasFilter("[DistrictId] IS NULL AND [DeletedAt] IS NULL")
.HasDatabaseName("UX_NurseServiceAreas_Nurse_City_WholeCity");
builder.HasIndex(a => new { a.NurseId, a.CityId, a.DistrictId })
.IsUnique()
.HasFilter("[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL")
.HasDatabaseName("UX_NurseServiceAreas_Nurse_City_District");
builder.HasOne<NurseProfile>()
.WithMany()
.HasForeignKey(a => a.NurseId)
.IsRequired();
builder.HasOne(a => a.City)
.WithMany()
.HasForeignKey(a => a.CityId)
.IsRequired();
builder.HasOne(a => a.District)
.WithMany()
.HasForeignKey(a => a.DistrictId)
.IsRequired(false);
builder.HasQueryFilter(a => a.DeletedAt == null);
}
}
@@ -0,0 +1,24 @@
using Baya.Domain.Entities.Geography;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.GeographyConfig;
internal sealed class ProvinceConfig : IEntityTypeConfiguration<Province>
{
public void Configure(EntityTypeBuilder<Province> builder)
{
builder.ToTable("Provinces", "geo");
builder.Property(p => p.NameFa).HasMaxLength(150).IsRequired();
builder.Property(p => p.NameEn).HasMaxLength(150).IsRequired();
builder.Property(p => p.SortOrder).HasDefaultValue(0);
builder.Property(p => p.IsActive).HasDefaultValue(true);
builder.HasIndex(p => p.SortOrder);
builder.HasQueryFilter(p => p.DeletedAt == null);
builder.HasData(GeographySeed.Provinces());
}
}
@@ -0,0 +1,46 @@
using Baya.Domain.Entities.Geography;
using Baya.Domain.Entities.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.IdentityConfig;
internal sealed class CustomerAddressConfig : IEntityTypeConfiguration<CustomerAddress>
{
public void Configure(EntityTypeBuilder<CustomerAddress> builder)
{
builder.ToTable("CustomerAddresses", "usr");
builder.Property(a => a.Title).HasMaxLength(100).IsRequired();
// address_line, postal_code and recipient contact are encrypted at rest (converters wired in
// ApplicationDbContext). The title and coordinates are not PII and stay plaintext.
builder.Property(a => a.Latitude).HasPrecision(9, 6);
builder.Property(a => a.Longitude).HasPrecision(9, 6);
builder.Property(a => a.IsPrimary).HasDefaultValue(false);
// Exactly one primary address per customer — the authoritative DB backstop the set-primary
// transaction must never trip. Excludes soft-deleted rows.
builder.HasIndex(a => a.CustomerId)
.IsUnique()
.HasFilter("[IsPrimary] = 1 AND [DeletedAt] IS NULL")
.HasDatabaseName("UX_CustomerAddresses_Customer_Primary");
builder.HasOne(a => a.Customer)
.WithMany()
.HasForeignKey(a => a.CustomerId)
.IsRequired();
builder.HasOne(a => a.City)
.WithMany()
.HasForeignKey(a => a.CityId)
.IsRequired();
builder.HasOne(a => a.District)
.WithMany()
.HasForeignKey(a => a.DistrictId)
.IsRequired(false);
builder.HasQueryFilter(a => a.DeletedAt == null);
}
}

Some files were not shown because too many files have changed in this diff Show More