diff --git a/dev/contracts/domains/geography-addresses.md b/dev/contracts/domains/geography-addresses.md new file mode 100644 index 0000000..6409ff3 --- /dev/null +++ b/dev/contracts/domains/geography-addresses.md @@ -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`. + +## 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`. + +## 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. diff --git a/dev/contracts/openapi/swagger.v1.json b/dev/contracts/openapi/swagger.v1.json index 89007e8..b6c1ed3 100644 --- a/dev/contracts/openapi/swagger.v1.json +++ b/dev/contracts/openapi/swagger.v1.json @@ -11,6 +11,771 @@ } ], "paths": { + "/api/v1/admin_geo/create_province": { + "post": { + "tags": [ + "AdminGeo" + ], + "operationId": "AdminGeo_CreateProvince", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProvinceCommand" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfProvinceDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_geo/update_province/{id}": { + "post": { + "tags": [ + "AdminGeo" + ], + "operationId": "AdminGeo_UpdateProvince", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateProvinceCommand" + } + } + }, + "required": true, + "x-position": 2 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfProvinceDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_geo/set_province_active/{id}": { + "post": { + "tags": [ + "AdminGeo" + ], + "operationId": "AdminGeo_SetProvinceActive", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetProvinceActiveCommand" + } + } + }, + "required": true, + "x-position": 2 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_geo/create_city": { + "post": { + "tags": [ + "AdminGeo" + ], + "operationId": "AdminGeo_CreateCity", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCityCommand" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfCityDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_geo/update_city/{id}": { + "post": { + "tags": [ + "AdminGeo" + ], + "operationId": "AdminGeo_UpdateCity", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCityCommand" + } + } + }, + "required": true, + "x-position": 2 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfCityDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_geo/set_city_active/{id}": { + "post": { + "tags": [ + "AdminGeo" + ], + "operationId": "AdminGeo_SetCityActive", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetCityActiveCommand" + } + } + }, + "required": true, + "x-position": 2 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_geo/create_district": { + "post": { + "tags": [ + "AdminGeo" + ], + "operationId": "AdminGeo_CreateDistrict", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDistrictCommand" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDistrictDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_geo/update_district/{id}": { + "post": { + "tags": [ + "AdminGeo" + ], + "operationId": "AdminGeo_UpdateDistrict", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateDistrictCommand" + } + } + }, + "required": true, + "x-position": 2 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDistrictDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_geo/set_district_active/{id}": { + "post": { + "tags": [ + "AdminGeo" + ], + "operationId": "AdminGeo_SetDistrictActive", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetDistrictActiveCommand" + } + } + }, + "required": true, + "x-position": 2 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/audit/get_audit_trail": { "get": { "tags": [ @@ -412,6 +1177,417 @@ ] } }, + "/api/v1/customer_addresses/create": { + "post": { + "tags": [ + "CustomerAddresses" + ], + "summary": "Creates a CustomerAddress", + "operationId": "CustomerAddresses_Create", + "requestBody": { + "x-name": "command", + "description": "A CustomerAddress representation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAddressCommand" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfCustomerAddressDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/customer_addresses/update/{id}": { + "post": { + "tags": [ + "CustomerAddresses" + ], + "summary": "Updates a CustomerAddress by unique id", + "operationId": "CustomerAddresses_Update", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "A CustomerAddress representation", + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAddressCommand" + } + } + }, + "required": true, + "x-position": 2 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfCustomerAddressDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/customer_addresses/set_primary/{id}": { + "post": { + "tags": [ + "CustomerAddresses" + ], + "operationId": "CustomerAddresses_SetPrimary", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/customer_addresses/delete/{id}": { + "delete": { + "tags": [ + "CustomerAddresses" + ], + "summary": "Deletes a CustomerAddress by unique id", + "operationId": "CustomerAddresses_Delete", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "A unique id for the CustomerAddress", + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/customer_addresses/list": { + "get": { + "tags": [ + "CustomerAddresses" + ], + "operationId": "CustomerAddresses_List", + "parameters": [ + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 1 + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 2 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfPagedResultOfCustomerAddressDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/customer_profiles/upsert": { "post": { "tags": [ @@ -554,6 +1730,270 @@ ] } }, + "/api/v1/geo/provinces": { + "get": { + "tags": [ + "Geo" + ], + "operationId": "Geo_Provinces", + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfIReadOnlyListOfProvinceDto" + } + } + } + } + } + } + }, + "/api/v1/geo/cities": { + "get": { + "tags": [ + "Geo" + ], + "operationId": "Geo_Cities", + "parameters": [ + { + "name": "province_id", + "x-originalName": "provinceId", + "in": "query", + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfIReadOnlyListOfCityDto" + } + } + } + } + } + } + }, + "/api/v1/geo/districts": { + "get": { + "tags": [ + "Geo" + ], + "operationId": "Geo_Districts", + "parameters": [ + { + "name": "city_id", + "x-originalName": "cityId", + "in": "query", + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfIReadOnlyListOfDistrictDto" + } + } + } + } + } + } + }, + "/api/v1/geo/tree": { + "get": { + "tags": [ + "Geo" + ], + "operationId": "Geo_Tree", + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfIReadOnlyListOfProvinceTreeDto" + } + } + } + } + } + } + }, "/api/v1/holidays/get_holidays": { "get": { "tags": [ @@ -1768,6 +3208,247 @@ ] } }, + "/api/v1/nurse_service_areas/add": { + "post": { + "tags": [ + "NurseServiceAreas" + ], + "operationId": "NurseServiceAreas_Add", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddNurseServiceAreaCommand" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfNurseServiceAreaDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/nurse_service_areas/remove/{id}": { + "delete": { + "tags": [ + "NurseServiceAreas" + ], + "summary": "Deletes a NurseServiceArea by unique id", + "operationId": "NurseServiceAreas_Remove", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "A unique id for the NurseServiceArea", + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/nurse_service_areas/list": { + "get": { + "tags": [ + "NurseServiceAreas" + ], + "operationId": "NurseServiceAreas_List", + "parameters": [ + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 1 + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 2 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfPagedResultOfNurseServiceAreaDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/patients/create": { "post": { "tags": [ @@ -2882,6 +4563,7 @@ "Forbidden", "NotFound", "NotAcceptable", + "Conflict", "EntityProcessError", "FailedDependency", "ServerError" @@ -2893,11 +4575,316 @@ 403, 404, 406, + 409, 422, 424, 500 ] }, + "ApiResultOfProvinceDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/ProvinceDto" + } + ] + } + } + } + ] + }, + "ProvinceDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "nameFa": { + "type": "string", + "nullable": true + }, + "nameEn": { + "type": "string", + "nullable": true + }, + "sortOrder": { + "type": "integer", + "format": "int32" + } + } + }, + "CreateProvinceCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "nameFa": { + "type": "string", + "nullable": true + }, + "nameEn": { + "type": "string", + "nullable": true + }, + "sortOrder": { + "type": "integer", + "format": "int32" + } + } + }, + "UpdateProvinceCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "nameFa": { + "type": "string", + "nullable": true + }, + "nameEn": { + "type": "string", + "nullable": true + }, + "sortOrder": { + "type": "integer", + "format": "int32" + } + } + }, + "SetProvinceActiveCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "isActive": { + "type": "boolean" + } + } + }, + "ApiResultOfCityDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/CityDto" + } + ] + } + } + } + ] + }, + "CityDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "provinceId": { + "type": "integer", + "format": "int64" + }, + "nameFa": { + "type": "string", + "nullable": true + }, + "nameEn": { + "type": "string", + "nullable": true + }, + "sortOrder": { + "type": "integer", + "format": "int32" + } + } + }, + "CreateCityCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "provinceId": { + "type": "integer", + "format": "int64" + }, + "nameFa": { + "type": "string", + "nullable": true + }, + "nameEn": { + "type": "string", + "nullable": true + }, + "sortOrder": { + "type": "integer", + "format": "int32" + } + } + }, + "UpdateCityCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "nameFa": { + "type": "string", + "nullable": true + }, + "nameEn": { + "type": "string", + "nullable": true + }, + "sortOrder": { + "type": "integer", + "format": "int32" + } + } + }, + "SetCityActiveCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "isActive": { + "type": "boolean" + } + } + }, + "ApiResultOfDistrictDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/DistrictDto" + } + ] + } + } + } + ] + }, + "DistrictDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "cityId": { + "type": "integer", + "format": "int64" + }, + "nameFa": { + "type": "string", + "nullable": true + }, + "nameEn": { + "type": "string", + "nullable": true + }, + "sortOrder": { + "type": "integer", + "format": "int32" + } + } + }, + "CreateDistrictCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "cityId": { + "type": "integer", + "format": "int64" + }, + "nameFa": { + "type": "string", + "nullable": true + }, + "nameEn": { + "type": "string", + "nullable": true + }, + "sortOrder": { + "type": "integer", + "format": "int32" + } + } + }, + "UpdateDistrictCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "nameFa": { + "type": "string", + "nullable": true + }, + "nameEn": { + "type": "string", + "nullable": true + }, + "sortOrder": { + "type": "integer", + "format": "int32" + } + } + }, + "SetDistrictActiveCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "isActive": { + "type": "boolean" + } + } + }, "ApiResultOfPagedResultOfAuditLogDto": { "allOf": [ { @@ -3111,6 +5098,218 @@ } } }, + "ApiResultOfCustomerAddressDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/CustomerAddressDto" + } + ] + } + } + } + ] + }, + "CustomerAddressDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "title": { + "type": "string", + "nullable": true + }, + "cityId": { + "type": "integer", + "format": "int64" + }, + "cityNameFa": { + "type": "string", + "nullable": true + }, + "cityNameEn": { + "type": "string", + "nullable": true + }, + "districtId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "districtNameFa": { + "type": "string", + "nullable": true + }, + "districtNameEn": { + "type": "string", + "nullable": true + }, + "addressLine": { + "type": "string", + "nullable": true + }, + "postalCode": { + "type": "string", + "nullable": true + }, + "latitude": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "longitude": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "isPrimary": { + "type": "boolean" + }, + "recipientName": { + "type": "string", + "nullable": true + }, + "recipientPhone": { + "type": "string", + "nullable": true + } + } + }, + "CreateAddressCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "title": { + "type": "string", + "nullable": true + }, + "cityId": { + "type": "integer", + "format": "int64" + }, + "districtId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "addressLine": { + "type": "string", + "nullable": true + }, + "postalCode": { + "type": "string", + "nullable": true + }, + "recipientName": { + "type": "string", + "nullable": true + }, + "recipientPhone": { + "type": "string", + "nullable": true + }, + "isPrimary": { + "type": "boolean" + } + } + }, + "UpdateAddressCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "title": { + "type": "string", + "nullable": true + }, + "cityId": { + "type": "integer", + "format": "int64" + }, + "districtId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "addressLine": { + "type": "string", + "nullable": true + }, + "postalCode": { + "type": "string", + "nullable": true + }, + "recipientName": { + "type": "string", + "nullable": true + }, + "recipientPhone": { + "type": "string", + "nullable": true + } + } + }, + "ApiResultOfPagedResultOfCustomerAddressDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/PagedResultOfCustomerAddressDto" + } + ] + } + } + } + ] + }, + "PagedResultOfCustomerAddressDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/CustomerAddressDto" + } + }, + "total": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + } + } + }, "ApiResultOfCustomerProfileDto": { "allOf": [ { @@ -3164,6 +5363,144 @@ } } }, + "ApiResultOfIReadOnlyListOfProvinceDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/ProvinceDto" + } + } + } + } + ] + }, + "ApiResultOfIReadOnlyListOfCityDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/CityDto" + } + } + } + } + ] + }, + "ApiResultOfIReadOnlyListOfDistrictDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/DistrictDto" + } + } + } + } + ] + }, + "ApiResultOfIReadOnlyListOfProvinceTreeDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/ProvinceTreeDto" + } + } + } + } + ] + }, + "ProvinceTreeDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "nameFa": { + "type": "string", + "nullable": true + }, + "nameEn": { + "type": "string", + "nullable": true + }, + "sortOrder": { + "type": "integer", + "format": "int32" + }, + "cities": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/CityTreeDto" + } + } + } + }, + "CityTreeDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "nameFa": { + "type": "string", + "nullable": true + }, + "nameEn": { + "type": "string", + "nullable": true + }, + "sortOrder": { + "type": "integer", + "format": "int32" + }, + "districts": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/DistrictDto" + } + } + } + }, "ApiResultOfPagedResultOfHolidayDto": { "allOf": [ { @@ -3654,6 +5991,129 @@ } } }, + "ApiResultOfNurseServiceAreaDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/NurseServiceAreaDto" + } + ] + } + } + } + ] + }, + "NurseServiceAreaDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "cityId": { + "type": "integer", + "format": "int64" + }, + "cityNameFa": { + "type": "string", + "nullable": true + }, + "cityNameEn": { + "type": "string", + "nullable": true + }, + "districtId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "districtNameFa": { + "type": "string", + "nullable": true + }, + "districtNameEn": { + "type": "string", + "nullable": true + }, + "isWholeCity": { + "type": "boolean" + }, + "isActive": { + "type": "boolean" + } + } + }, + "AddNurseServiceAreaCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "cityId": { + "type": "integer", + "format": "int64" + }, + "districtId": { + "type": "integer", + "format": "int64", + "nullable": true + } + } + }, + "ApiResultOfPagedResultOfNurseServiceAreaDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/PagedResultOfNurseServiceAreaDto" + } + ] + } + } + } + ] + }, + "PagedResultOfNurseServiceAreaDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/NurseServiceAreaDto" + } + }, + "total": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + } + } + }, "ApiResultOfPatientDto": { "allOf": [ { diff --git a/dev/shared-working-context/backend/STATUS.md b/dev/shared-working-context/backend/STATUS.md index 1b6e6cb..98efaf5 100644 --- a/dev/shared-working-context/backend/STATUS.md +++ b/dev/shared-working-context/backend/STATUS.md @@ -12,6 +12,28 @@ One block per completed backend phase. Newest at the top. Backend lane writes he - **Notes for frontend:** --> +## 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; diff --git a/dev/shared-working-context/backend/handoff/after-backend-phase-4.md b/dev/shared-working-context/backend/handoff/after-backend-phase-4.md new file mode 100644 index 0000000..7afbc5e --- /dev/null +++ b/dev/shared-working-context/backend/handoff/after-backend-phase-4.md @@ -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. diff --git a/dev/shared-working-context/reports/backend-phase-4-report.md b/dev/shared-working-context/reports/backend-phase-4-report.md new file mode 100644 index 0000000..129f216 --- /dev/null +++ b/dev/shared-working-context/reports/backend-phase-4-report.md @@ -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`, 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. diff --git a/dev/shared-working-context/reports/mocks-registry.md b/dev/shared-working-context/reports/mocks-registry.md index e40e33e..eafb35d 100644 --- a/dev/shared-working-context/reports/mocks-registry.md +++ b/dev/shared-working-context/reports/mocks-registry.md @@ -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 | 🟡 | diff --git a/product/data-model/01-identity-and-access.html b/product/data-model/01-identity-and-access.html index ea7619e..43f3b9e 100644 --- a/product/data-model/01-identity-and-access.html +++ b/product/data-model/01-identity-and-access.html @@ -54,6 +54,8 @@

Role: The person receiving care, separate from the payer. Why: 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: id, customer_id, display_name, first_name, last_name, birth_date, gender, blood_type, initial_medical_notes (enc), is_active, timestamps. Relations: N:1 → customer_profiles; 1:N → booking_requests, patient_care_records. Tenancy invariant: a booking_request.patient_id must belong to the same customer_id.

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.CustomerAddressesaddress_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.

diff --git a/product/data-model/01-identity-and-access.md b/product/data-model/01-identity-and-access.md index 572647f..6782f9b 100644 --- a/product/data-model/01-identity-and-access.md +++ b/product/data-model/01-identity-and-access.md @@ -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. diff --git a/product/data-model/02-geography.html b/product/data-model/02-geography.html index fdcc63c..5ff038f 100644 --- a/product/data-model/02-geography.html +++ b/product/data-model/02-geography.html @@ -21,6 +21,8 @@

Role: The geo hierarchy backing service areas, addresses, and search. Why a table, not a static list: new cities/districts launch without a deploy, and sort_order/is_active drive ordered, toggleable dropdowns. districts map to Tehran's 22 municipal districts or major neighborhoods elsewhere; they are optional (a nurse can cover a whole city). Fields unchanged. Relations: provinces 1:N cities 1:N districts; referenced by customer_addresses and nurse_service_areas.

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 pairUNIQUE(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.

+
↑ Back to top diff --git a/product/data-model/02-geography.md b/product/data-model/02-geography.md index 8a724c4..09a8f6d 100644 --- a/product/data-model/02-geography.md +++ b/product/data-model/02-geography.md @@ -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. diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 1d93437..df36993 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -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` in the Application assembly as `IValidator` 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 diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/AdminGeoController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/AdminGeoController.cs new file mode 100644 index 0000000..0a0ad65 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/AdminGeoController.cs @@ -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] + public async Task CreateProvince(CreateProvinceCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpPost("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task UpdateProvince(long id, UpdateProvinceCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command with { Id = id }, cancellationToken)); + + [HttpPost("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task SetProvinceActive(long id, SetProvinceActiveCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command with { Id = id }, cancellationToken)); + + [HttpPost("[action]")] + [ProducesOkApiResponseType] + public async Task CreateCity(CreateCityCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpPost("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task UpdateCity(long id, UpdateCityCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command with { Id = id }, cancellationToken)); + + [HttpPost("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task SetCityActive(long id, SetCityActiveCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command with { Id = id }, cancellationToken)); + + [HttpPost("[action]")] + [ProducesOkApiResponseType] + public async Task CreateDistrict(CreateDistrictCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpPost("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task UpdateDistrict(long id, UpdateDistrictCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command with { Id = id }, cancellationToken)); + + [HttpPost("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task SetDistrictActive(long id, SetDistrictActiveCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command with { Id = id }, cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/CustomerAddressesController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/CustomerAddressesController.cs new file mode 100644 index 0000000..ee30742 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/CustomerAddressesController.cs @@ -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] + public async Task Create(CreateAddressCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpPost("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task Update(long id, UpdateAddressCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command with { Id = id }, cancellationToken)); + + [HttpPost("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task SetPrimary(long id, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new SetPrimaryAddressCommand(id), cancellationToken)); + + [HttpDelete("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task Delete(long id, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new DeleteAddressCommand(id), cancellationToken)); + + [HttpGet("[action]")] + [ProducesOkApiResponseType>] + public async Task List([FromQuery] ListMyAddressesQuery query, CancellationToken cancellationToken) + => OperationResult(await sender.Send(query, cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/GeoController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/GeoController.cs new file mode 100644 index 0000000..86df717 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/GeoController.cs @@ -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>] + public async Task Provinces(CancellationToken cancellationToken) + => OperationResult(await sender.Send(new ListProvincesQuery(), cancellationToken)); + + [HttpGet("[action]")] + [ProducesOkApiResponseType>] + public async Task Cities([FromQuery(Name = "province_id")] long provinceId, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new ListCitiesQuery(provinceId), cancellationToken)); + + [HttpGet("[action]")] + [ProducesOkApiResponseType>] + public async Task Districts([FromQuery(Name = "city_id")] long cityId, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new ListDistrictsQuery(cityId), cancellationToken)); + + [HttpGet("[action]")] + [ProducesOkApiResponseType>] + public async Task Tree(CancellationToken cancellationToken) + => OperationResult(await sender.Send(new GetGeoTreeQuery(), cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/NurseServiceAreasController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/NurseServiceAreasController.cs new file mode 100644 index 0000000..2558de9 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/NurseServiceAreasController.cs @@ -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] + public async Task Add(AddNurseServiceAreaCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpDelete("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task Remove(long id, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new RemoveNurseServiceAreaCommand(id), cancellationToken)); + + [HttpGet("[action]")] + [ProducesOkApiResponseType>] + public async Task List([FromQuery] ListMyServiceAreasQuery query, CancellationToken cancellationToken) + => OperationResult(await sender.Send(query, cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/appsettings.Development.json b/server/src/API/Baya.Web.Api/appsettings.Development.json index 2f3567a..0fe82b8 100644 --- a/server/src/API/Baya.Web.Api/appsettings.Development.json +++ b/server/src/API/Baya.Web.Api/appsettings.Development.json @@ -18,6 +18,11 @@ }, "ObjectStorage": { "RootPath": "" + }, + "Geocoding": { + "ReturnNullCoordinates": false, + "LowConfidenceMarker": "NO_GEO", + "ResolvedConfidence": 0.9 } }, "AllowedHosts": "*", diff --git a/server/src/API/Baya.Web.Api/appsettings.json b/server/src/API/Baya.Web.Api/appsettings.json index 2f3567a..0fe82b8 100644 --- a/server/src/API/Baya.Web.Api/appsettings.json +++ b/server/src/API/Baya.Web.Api/appsettings.json @@ -18,6 +18,11 @@ }, "ObjectStorage": { "RootPath": "" + }, + "Geocoding": { + "ReturnNullCoordinates": false, + "LowConfidenceMarker": "NO_GEO", + "ResolvedConfidence": 0.9 } }, "AllowedHosts": "*", diff --git a/server/src/API/Baya.WebFramework/BaseController/BaseController.cs b/server/src/API/Baya.WebFramework/BaseController/BaseController.cs index 522f241..f1be015 100644 --- a/server/src/API/Baya.WebFramework/BaseController/BaseController.cs +++ b/server/src/API/Baya.WebFramework/BaseController/BaseController.cs @@ -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); diff --git a/server/src/Core/Baya.Application/Contracts/Common/IGeocoder.cs b/server/src/Core/Baya.Application/Contracts/Common/IGeocoder.cs new file mode 100644 index 0000000..cd69c3b --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Common/IGeocoder.cs @@ -0,0 +1,36 @@ +#nullable enable +namespace Baya.Application.Contracts.Common; + +/// +/// 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 (never float) so the +/// EVV distance check (b9) that later consumes them is exact. +/// +public interface IGeocoder +{ + /// + /// Resolves the free-text (within the named + /// and optional ) to coordinates. A low-confidence / unresolvable + /// address yields a result with / + /// left null — the address is still saved, just without a map pin. + /// + ValueTask GeocodeAsync( + string addressText, + string cityName, + string? districtName, + CancellationToken cancellationToken = default); +} + +/// +/// The outcome of a geocoding attempt. / are null when +/// the provider could not resolve the address with enough confidence. +/// +public sealed record GeocodeResult( + decimal? Latitude, + decimal? Longitude, + string FormattedAddress, + double Confidence) +{ + public bool HasCoordinates => Latitude is not null && Longitude is not null; +} diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/ICustomerAddressRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/ICustomerAddressRepository.cs new file mode 100644 index 0000000..8af78cf --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Persistence/ICustomerAddressRepository.cs @@ -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); + + /// Tracked, tenancy-scoped lookup — returns the address only if it belongs to + /// , else null. + Task GetOwnedAsync(long id, long customerId, CancellationToken cancellationToken); + + /// Whether the customer already has at least one address (the first becomes primary). + Task HasAnyAsync(long customerId, CancellationToken cancellationToken); + + /// Clears the current primary (if any) other than for the + /// customer, in a single transaction, so the filtered UNIQUE(customer_id) WHERE is_primary=1 + /// index never trips. The caller has already verified tenancy and set the new primary. + Task ClearOtherPrimaryAsync(long customerId, long addressId, CancellationToken cancellationToken); + + /// Atomically makes primary and clears the prior primary + /// (clear-then-set order). The address must already be owned by the customer. + Task SetPrimaryAsync(long customerId, long addressId, CancellationToken cancellationToken); + + /// No-tracking, paginated projection of the customer's own addresses (primary first), with the + /// PII decrypted for the owner. + Task> ListAsync(long customerId, int page, int pageSize, CancellationToken cancellationToken); +} diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IGeoRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IGeoRepository.cs new file mode 100644 index 0000000..08d6bf5 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IGeoRepository.cs @@ -0,0 +1,41 @@ +#nullable enable +using Baya.Application.Models.Geography; +using Baya.Domain.Entities.Geography; + +namespace Baya.Application.Contracts.Persistence; + +/// +/// 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. +/// +public interface IGeoRepository +{ + Task> ListActiveProvincesAsync(CancellationToken cancellationToken); + + /// Active cities under an active province, ordered — empty if the province is inactive/absent. + Task> ListActiveCitiesAsync(long provinceId, CancellationToken cancellationToken); + + /// Active districts under an active city, ordered — empty is valid (whole-city-only city). + Task> ListActiveDistrictsAsync(long cityId, CancellationToken cancellationToken); + + /// The full active province→city→district tree in one payload for the dropdown. + Task> GetActiveTreeAsync(CancellationToken cancellationToken); + + // Admin: tracked lookups (include inactive, exclude soft-deleted) for edit/toggle. + Task GetProvinceAsync(long id, CancellationToken cancellationToken); + Task GetCityAsync(long id, CancellationToken cancellationToken); + Task GetDistrictAsync(long id, CancellationToken cancellationToken); + + Task AddProvinceAsync(Province province, CancellationToken cancellationToken); + Task AddCityAsync(City city, CancellationToken cancellationToken); + Task AddDistrictAsync(District district, CancellationToken cancellationToken); + + Task ProvinceExistsAsync(long provinceId, CancellationToken cancellationToken); + + /// True when the city exists, is active, and its province is active. + Task IsCityActiveAsync(long cityId, CancellationToken cancellationToken); + + /// True when the district exists, is active, belongs to , and that + /// city (and its province) are active. + Task IsDistrictInActiveCityAsync(long districtId, long cityId, CancellationToken cancellationToken); +} diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/INurseServiceAreaRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/INurseServiceAreaRepository.cs new file mode 100644 index 0000000..7ad2171 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Persistence/INurseServiceAreaRepository.cs @@ -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); + + /// Tracked, tenancy-scoped lookup — returns the area only if it belongs to + /// , else null. + Task GetOwnedAsync(long id, long nurseId, CancellationToken cancellationToken); + + /// Whether the nurse already declared this exact coverage (a NULL + /// is the whole-city row) — the clean 409 guard ahead of the filtered unique index backstop. + Task DuplicateExistsAsync(long nurseId, long cityId, long? districtId, CancellationToken cancellationToken); + + /// 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. + Task> ListAsync(long nurseId, int page, int pageSize, CancellationToken cancellationToken); +} diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs index de0284d..d858647 100644 --- a/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs @@ -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(); } diff --git a/server/src/Core/Baya.Application/Features/Addresses/Commands/CreateAddress/CreateAddressCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Addresses/Commands/CreateAddress/CreateAddressCommand.Handler.cs new file mode 100644 index 0000000..abdeb76 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Addresses/Commands/CreateAddress/CreateAddressCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(CreateAddressCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + if (currentUser.Roles?.Contains(RoleNames.Customer) != true) + return OperationResult.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.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.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.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); +} diff --git a/server/src/Core/Baya.Application/Features/Addresses/Commands/CreateAddress/CreateAddressCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Addresses/Commands/CreateAddress/CreateAddressCommand.Validator.cs new file mode 100644 index 0000000..f2ac32c --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Addresses/Commands/CreateAddress/CreateAddressCommand.Validator.cs @@ -0,0 +1,18 @@ +using FluentValidation; + +namespace Baya.Application.Features.Addresses.Commands.CreateAddress; + +public sealed class CreateAddressCommandValidator : AbstractValidator +{ + 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."); + } +} diff --git a/server/src/Core/Baya.Application/Features/Addresses/Commands/CreateAddress/CreateAddressCommand.cs b/server/src/Core/Baya.Application/Features/Addresses/Commands/CreateAddress/CreateAddressCommand.cs new file mode 100644 index 0000000..000d5f5 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Addresses/Commands/CreateAddress/CreateAddressCommand.cs @@ -0,0 +1,20 @@ +using Baya.Application.Models.Addresses; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Addresses.Commands.CreateAddress; + +/// +/// 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 IGeocoder seam. The first address (or one +/// created with IsPrimary=true) becomes the single primary. Tenancy is from the caller. +/// +public record CreateAddressCommand( + string Title, + long CityId, + long? DistrictId, + string AddressLine, + string PostalCode, + string RecipientName, + string RecipientPhone, + bool IsPrimary) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Addresses/Commands/DeleteAddress/DeleteAddressCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Addresses/Commands/DeleteAddress/DeleteAddressCommand.Handler.cs new file mode 100644 index 0000000..ee21167 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Addresses/Commands/DeleteAddress/DeleteAddressCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(DeleteAddressCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + if (currentUser.Roles?.Contains(RoleNames.Customer) != true) + return OperationResult.ForbiddenResult("Only a customer can delete an address."); + + var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (customerId is not { } cid) + return OperationResult.NotFoundResult("Address not found."); + + var address = await unitOfWork.CustomerAddressRepository.GetOwnedAsync(request.Id, cid, cancellationToken); + if (address is null) + return OperationResult.NotFoundResult("Address not found."); + + address.DeletedAt = clock.UtcNow; + await unitOfWork.CommitAsync(); + + return OperationResult.SuccessResult(true); + } +} diff --git a/server/src/Core/Baya.Application/Features/Addresses/Commands/DeleteAddress/DeleteAddressCommand.cs b/server/src/Core/Baya.Application/Features/Addresses/Commands/DeleteAddress/DeleteAddressCommand.cs new file mode 100644 index 0000000..2ea15a4 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Addresses/Commands/DeleteAddress/DeleteAddressCommand.cs @@ -0,0 +1,7 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Addresses.Commands.DeleteAddress; + +/// Soft-deletes one of the signed-in customer's own addresses (tenancy-checked). +public record DeleteAddressCommand(long Id) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Addresses/Commands/SetPrimaryAddress/SetPrimaryAddressCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Addresses/Commands/SetPrimaryAddress/SetPrimaryAddressCommand.Handler.cs new file mode 100644 index 0000000..cd6d5fd --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Addresses/Commands/SetPrimaryAddress/SetPrimaryAddressCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(SetPrimaryAddressCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + if (currentUser.Roles?.Contains(RoleNames.Customer) != true) + return OperationResult.ForbiddenResult("Only a customer can manage addresses."); + + var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (customerId is not { } cid) + return OperationResult.NotFoundResult("Address not found."); + + var address = await unitOfWork.CustomerAddressRepository.GetOwnedAsync(request.Id, cid, cancellationToken); + if (address is null) + return OperationResult.NotFoundResult("Address not found."); + + if (!address.IsPrimary) + await unitOfWork.CustomerAddressRepository.SetPrimaryAsync(cid, request.Id, cancellationToken); + + return OperationResult.SuccessResult(true); + } +} diff --git a/server/src/Core/Baya.Application/Features/Addresses/Commands/SetPrimaryAddress/SetPrimaryAddressCommand.cs b/server/src/Core/Baya.Application/Features/Addresses/Commands/SetPrimaryAddress/SetPrimaryAddressCommand.cs new file mode 100644 index 0000000..bfbcefb --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Addresses/Commands/SetPrimaryAddress/SetPrimaryAddressCommand.cs @@ -0,0 +1,8 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Addresses.Commands.SetPrimaryAddress; + +/// Atomically makes one of the customer's own addresses primary and clears the previous one +/// (the single-primary invariant). Tenancy-checked. +public record SetPrimaryAddressCommand(long Id) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Addresses/Commands/UpdateAddress/UpdateAddressCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Addresses/Commands/UpdateAddress/UpdateAddressCommand.Handler.cs new file mode 100644 index 0000000..41754b4 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Addresses/Commands/UpdateAddress/UpdateAddressCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(UpdateAddressCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + if (currentUser.Roles?.Contains(RoleNames.Customer) != true) + return OperationResult.ForbiddenResult("Only a customer can edit an address."); + + var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (customerId is not { } cid) + return OperationResult.NotFoundResult("Address not found."); + + var address = await unitOfWork.CustomerAddressRepository.GetOwnedAsync(request.Id, cid, cancellationToken); + if (address is null) + return OperationResult.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.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.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.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)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Addresses/Commands/UpdateAddress/UpdateAddressCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Addresses/Commands/UpdateAddress/UpdateAddressCommand.Validator.cs new file mode 100644 index 0000000..b3de833 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Addresses/Commands/UpdateAddress/UpdateAddressCommand.Validator.cs @@ -0,0 +1,18 @@ +using FluentValidation; + +namespace Baya.Application.Features.Addresses.Commands.UpdateAddress; + +public sealed class UpdateAddressCommandValidator : AbstractValidator +{ + 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."); + } +} diff --git a/server/src/Core/Baya.Application/Features/Addresses/Commands/UpdateAddress/UpdateAddressCommand.cs b/server/src/Core/Baya.Application/Features/Addresses/Commands/UpdateAddress/UpdateAddressCommand.cs new file mode 100644 index 0000000..c9beda9 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Addresses/Commands/UpdateAddress/UpdateAddressCommand.cs @@ -0,0 +1,17 @@ +using Baya.Application.Models.Addresses; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Addresses.Commands.UpdateAddress; + +/// Edits an address the signed-in customer owns. Id comes from the route; PII is +/// re-encrypted and coordinates are re-geocoded when the street line/city/district changes. +public record UpdateAddressCommand( + long Id, + string Title, + long CityId, + long? DistrictId, + string AddressLine, + string PostalCode, + string RecipientName, + string RecipientPhone) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Addresses/Queries/ListMyAddresses/ListMyAddressesQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Addresses/Queries/ListMyAddresses/ListMyAddressesQuery.Handler.cs new file mode 100644 index 0000000..2998e2d --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Addresses/Queries/ListMyAddresses/ListMyAddressesQuery.Handler.cs @@ -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>> +{ + public async ValueTask>> Handle(ListMyAddressesQuery request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult>.UnauthorizedResult("Not authenticated."); + + if (currentUser.Roles?.Contains(RoleNames.Customer) != true) + return OperationResult>.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>.SuccessResult( + new PagedResult([], 0, page, pageSize)); + + var result = await unitOfWork.CustomerAddressRepository.ListAsync(cid, page, pageSize, cancellationToken); + return OperationResult>.SuccessResult(result); + } +} diff --git a/server/src/Core/Baya.Application/Features/Addresses/Queries/ListMyAddresses/ListMyAddressesQuery.cs b/server/src/Core/Baya.Application/Features/Addresses/Queries/ListMyAddresses/ListMyAddressesQuery.cs new file mode 100644 index 0000000..546aa35 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Addresses/Queries/ListMyAddresses/ListMyAddressesQuery.cs @@ -0,0 +1,10 @@ +using Baya.Application.Models.Addresses; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Addresses.Queries.ListMyAddresses; + +/// The signed-in customer's own addresses, primary first (tenancy-scoped, paginated). The PII is +/// decrypted for the owner. +public record ListMyAddressesQuery(int Page = 1, int PageSize = 50) + : IRequest>>; diff --git a/server/src/Core/Baya.Application/Features/Geography/Commands/CreateCity/CreateCityCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Geography/Commands/CreateCity/CreateCityCommand.Handler.cs new file mode 100644 index 0000000..a7ea32a --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Commands/CreateCity/CreateCityCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(CreateCityCommand request, CancellationToken cancellationToken) + { + if (!await unitOfWork.GeoRepository.ProvinceExistsAsync(request.ProvinceId, cancellationToken)) + return OperationResult.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.SuccessResult( + new CityDto(city.Id, city.ProvinceId, city.NameFa, city.NameEn, city.SortOrder)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Geography/Commands/CreateCity/CreateCityCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Geography/Commands/CreateCity/CreateCityCommand.Validator.cs new file mode 100644 index 0000000..74bc68f --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Commands/CreateCity/CreateCityCommand.Validator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace Baya.Application.Features.Geography.Commands.CreateCity; + +public sealed class CreateCityCommandValidator : AbstractValidator +{ + public CreateCityCommandValidator() + { + RuleFor(x => x.ProvinceId).GreaterThan(0); + RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150); + RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150); + } +} diff --git a/server/src/Core/Baya.Application/Features/Geography/Commands/CreateCity/CreateCityCommand.cs b/server/src/Core/Baya.Application/Features/Geography/Commands/CreateCity/CreateCityCommand.cs new file mode 100644 index 0000000..2ad42cb --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Commands/CreateCity/CreateCityCommand.cs @@ -0,0 +1,9 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Geography; +using Mediator; + +namespace Baya.Application.Features.Geography.Commands.CreateCity; + +/// Admin: create a city under a province. Invalidates the geo cache. +public record CreateCityCommand(long ProvinceId, string NameFa, string NameEn, int SortOrder) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Geography/Commands/CreateDistrict/CreateDistrictCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Geography/Commands/CreateDistrict/CreateDistrictCommand.Handler.cs new file mode 100644 index 0000000..d913956 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Commands/CreateDistrict/CreateDistrictCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(CreateDistrictCommand request, CancellationToken cancellationToken) + { + var city = await unitOfWork.GeoRepository.GetCityAsync(request.CityId, cancellationToken); + if (city is null) + return OperationResult.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.SuccessResult( + new DistrictDto(district.Id, district.CityId, district.NameFa, district.NameEn, district.SortOrder)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Geography/Commands/CreateDistrict/CreateDistrictCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Geography/Commands/CreateDistrict/CreateDistrictCommand.Validator.cs new file mode 100644 index 0000000..fedf2af --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Commands/CreateDistrict/CreateDistrictCommand.Validator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace Baya.Application.Features.Geography.Commands.CreateDistrict; + +public sealed class CreateDistrictCommandValidator : AbstractValidator +{ + public CreateDistrictCommandValidator() + { + RuleFor(x => x.CityId).GreaterThan(0); + RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150); + RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150); + } +} diff --git a/server/src/Core/Baya.Application/Features/Geography/Commands/CreateDistrict/CreateDistrictCommand.cs b/server/src/Core/Baya.Application/Features/Geography/Commands/CreateDistrict/CreateDistrictCommand.cs new file mode 100644 index 0000000..e543103 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Commands/CreateDistrict/CreateDistrictCommand.cs @@ -0,0 +1,9 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Geography; +using Mediator; + +namespace Baya.Application.Features.Geography.Commands.CreateDistrict; + +/// Admin: add a district under a city (e.g. a neighborhood outside Tehran). Invalidates cache. +public record CreateDistrictCommand(long CityId, string NameFa, string NameEn, int SortOrder) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Geography/Commands/CreateProvince/CreateProvinceCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Geography/Commands/CreateProvince/CreateProvinceCommand.Handler.cs new file mode 100644 index 0000000..c24f19b --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Commands/CreateProvince/CreateProvinceCommand.Handler.cs @@ -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> +{ + public async ValueTask> 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.SuccessResult( + new ProvinceDto(province.Id, province.NameFa, province.NameEn, province.SortOrder)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Geography/Commands/CreateProvince/CreateProvinceCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Geography/Commands/CreateProvince/CreateProvinceCommand.Validator.cs new file mode 100644 index 0000000..28395ad --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Commands/CreateProvince/CreateProvinceCommand.Validator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Baya.Application.Features.Geography.Commands.CreateProvince; + +public sealed class CreateProvinceCommandValidator : AbstractValidator +{ + public CreateProvinceCommandValidator() + { + RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150); + RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150); + } +} diff --git a/server/src/Core/Baya.Application/Features/Geography/Commands/CreateProvince/CreateProvinceCommand.cs b/server/src/Core/Baya.Application/Features/Geography/Commands/CreateProvince/CreateProvinceCommand.cs new file mode 100644 index 0000000..fcc99cf --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Commands/CreateProvince/CreateProvinceCommand.cs @@ -0,0 +1,9 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Geography; +using Mediator; + +namespace Baya.Application.Features.Geography.Commands.CreateProvince; + +/// Admin: create a province. New provinces launch by insert — no deploy. Invalidates the geo cache. +public record CreateProvinceCommand(string NameFa, string NameEn, int SortOrder) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Geography/Commands/SetCityActive/SetCityActiveCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Geography/Commands/SetCityActive/SetCityActiveCommand.Handler.cs new file mode 100644 index 0000000..4b841ca --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Commands/SetCityActive/SetCityActiveCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(SetCityActiveCommand request, CancellationToken cancellationToken) + { + var city = await unitOfWork.GeoRepository.GetCityAsync(request.Id, cancellationToken); + if (city is null) + return OperationResult.NotFoundResult("City not found."); + + city.IsActive = request.IsActive; + + await unitOfWork.CommitAsync(); + await GeoCache.InvalidateAsync(cache, cancellationToken); + + return OperationResult.SuccessResult(true); + } +} diff --git a/server/src/Core/Baya.Application/Features/Geography/Commands/SetCityActive/SetCityActiveCommand.cs b/server/src/Core/Baya.Application/Features/Geography/Commands/SetCityActive/SetCityActiveCommand.cs new file mode 100644 index 0000000..51a6b6f --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Commands/SetCityActive/SetCityActiveCommand.cs @@ -0,0 +1,8 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Geography.Commands.SetCityActive; + +/// 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. +public record SetCityActiveCommand(long Id, bool IsActive) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Geography/Commands/SetDistrictActive/SetDistrictActiveCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Geography/Commands/SetDistrictActive/SetDistrictActiveCommand.Handler.cs new file mode 100644 index 0000000..57c7ab0 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Commands/SetDistrictActive/SetDistrictActiveCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(SetDistrictActiveCommand request, CancellationToken cancellationToken) + { + var district = await unitOfWork.GeoRepository.GetDistrictAsync(request.Id, cancellationToken); + if (district is null) + return OperationResult.NotFoundResult("District not found."); + + district.IsActive = request.IsActive; + + await unitOfWork.CommitAsync(); + await GeoCache.InvalidateAsync(cache, cancellationToken); + + return OperationResult.SuccessResult(true); + } +} diff --git a/server/src/Core/Baya.Application/Features/Geography/Commands/SetDistrictActive/SetDistrictActiveCommand.cs b/server/src/Core/Baya.Application/Features/Geography/Commands/SetDistrictActive/SetDistrictActiveCommand.cs new file mode 100644 index 0000000..f0ba787 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Commands/SetDistrictActive/SetDistrictActiveCommand.cs @@ -0,0 +1,7 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Geography.Commands.SetDistrictActive; + +/// Admin: toggle a district's active flag (no delete). Invalidates cache. +public record SetDistrictActiveCommand(long Id, bool IsActive) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Geography/Commands/SetProvinceActive/SetProvinceActiveCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Geography/Commands/SetProvinceActive/SetProvinceActiveCommand.Handler.cs new file mode 100644 index 0000000..aa3037c --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Commands/SetProvinceActive/SetProvinceActiveCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(SetProvinceActiveCommand request, CancellationToken cancellationToken) + { + var province = await unitOfWork.GeoRepository.GetProvinceAsync(request.Id, cancellationToken); + if (province is null) + return OperationResult.NotFoundResult("Province not found."); + + province.IsActive = request.IsActive; + + await unitOfWork.CommitAsync(); + await GeoCache.InvalidateAsync(cache, cancellationToken); + + return OperationResult.SuccessResult(true); + } +} diff --git a/server/src/Core/Baya.Application/Features/Geography/Commands/SetProvinceActive/SetProvinceActiveCommand.cs b/server/src/Core/Baya.Application/Features/Geography/Commands/SetProvinceActive/SetProvinceActiveCommand.cs new file mode 100644 index 0000000..3e1a664 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Commands/SetProvinceActive/SetProvinceActiveCommand.cs @@ -0,0 +1,8 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Geography.Commands.SetProvinceActive; + +/// 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. +public record SetProvinceActiveCommand(long Id, bool IsActive) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Geography/Commands/UpdateCity/UpdateCityCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Geography/Commands/UpdateCity/UpdateCityCommand.Handler.cs new file mode 100644 index 0000000..36c79d5 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Commands/UpdateCity/UpdateCityCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(UpdateCityCommand request, CancellationToken cancellationToken) + { + var city = await unitOfWork.GeoRepository.GetCityAsync(request.Id, cancellationToken); + if (city is null) + return OperationResult.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.SuccessResult( + new CityDto(city.Id, city.ProvinceId, city.NameFa, city.NameEn, city.SortOrder)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Geography/Commands/UpdateCity/UpdateCityCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Geography/Commands/UpdateCity/UpdateCityCommand.Validator.cs new file mode 100644 index 0000000..ad07c4e --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Commands/UpdateCity/UpdateCityCommand.Validator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Baya.Application.Features.Geography.Commands.UpdateCity; + +public sealed class UpdateCityCommandValidator : AbstractValidator +{ + public UpdateCityCommandValidator() + { + RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150); + RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150); + } +} diff --git a/server/src/Core/Baya.Application/Features/Geography/Commands/UpdateCity/UpdateCityCommand.cs b/server/src/Core/Baya.Application/Features/Geography/Commands/UpdateCity/UpdateCityCommand.cs new file mode 100644 index 0000000..c7c0c37 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Commands/UpdateCity/UpdateCityCommand.cs @@ -0,0 +1,9 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Geography; +using Mediator; + +namespace Baya.Application.Features.Geography.Commands.UpdateCity; + +/// Admin: edit a city's names/sort order. Id comes from the route. Invalidates cache. +public record UpdateCityCommand(long Id, string NameFa, string NameEn, int SortOrder) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Geography/Commands/UpdateDistrict/UpdateDistrictCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Geography/Commands/UpdateDistrict/UpdateDistrictCommand.Handler.cs new file mode 100644 index 0000000..b3852a1 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Commands/UpdateDistrict/UpdateDistrictCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(UpdateDistrictCommand request, CancellationToken cancellationToken) + { + var district = await unitOfWork.GeoRepository.GetDistrictAsync(request.Id, cancellationToken); + if (district is null) + return OperationResult.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.SuccessResult( + new DistrictDto(district.Id, district.CityId, district.NameFa, district.NameEn, district.SortOrder)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Geography/Commands/UpdateDistrict/UpdateDistrictCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Geography/Commands/UpdateDistrict/UpdateDistrictCommand.Validator.cs new file mode 100644 index 0000000..e31e4c1 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Commands/UpdateDistrict/UpdateDistrictCommand.Validator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Baya.Application.Features.Geography.Commands.UpdateDistrict; + +public sealed class UpdateDistrictCommandValidator : AbstractValidator +{ + public UpdateDistrictCommandValidator() + { + RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150); + RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150); + } +} diff --git a/server/src/Core/Baya.Application/Features/Geography/Commands/UpdateDistrict/UpdateDistrictCommand.cs b/server/src/Core/Baya.Application/Features/Geography/Commands/UpdateDistrict/UpdateDistrictCommand.cs new file mode 100644 index 0000000..575455e --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Commands/UpdateDistrict/UpdateDistrictCommand.cs @@ -0,0 +1,9 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Geography; +using Mediator; + +namespace Baya.Application.Features.Geography.Commands.UpdateDistrict; + +/// Admin: edit a district's names/sort order. Id comes from the route. Invalidates cache. +public record UpdateDistrictCommand(long Id, string NameFa, string NameEn, int SortOrder) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Geography/Commands/UpdateProvince/UpdateProvinceCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Geography/Commands/UpdateProvince/UpdateProvinceCommand.Handler.cs new file mode 100644 index 0000000..f849407 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Commands/UpdateProvince/UpdateProvinceCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(UpdateProvinceCommand request, CancellationToken cancellationToken) + { + var province = await unitOfWork.GeoRepository.GetProvinceAsync(request.Id, cancellationToken); + if (province is null) + return OperationResult.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.SuccessResult( + new ProvinceDto(province.Id, province.NameFa, province.NameEn, province.SortOrder)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Geography/Commands/UpdateProvince/UpdateProvinceCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Geography/Commands/UpdateProvince/UpdateProvinceCommand.Validator.cs new file mode 100644 index 0000000..ae67966 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Commands/UpdateProvince/UpdateProvinceCommand.Validator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Baya.Application.Features.Geography.Commands.UpdateProvince; + +public sealed class UpdateProvinceCommandValidator : AbstractValidator +{ + public UpdateProvinceCommandValidator() + { + RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150); + RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150); + } +} diff --git a/server/src/Core/Baya.Application/Features/Geography/Commands/UpdateProvince/UpdateProvinceCommand.cs b/server/src/Core/Baya.Application/Features/Geography/Commands/UpdateProvince/UpdateProvinceCommand.cs new file mode 100644 index 0000000..8402911 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Commands/UpdateProvince/UpdateProvinceCommand.cs @@ -0,0 +1,9 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Geography; +using Mediator; + +namespace Baya.Application.Features.Geography.Commands.UpdateProvince; + +/// Admin: edit a province's names/sort order. Id comes from the route. Invalidates cache. +public record UpdateProvinceCommand(long Id, string NameFa, string NameEn, int SortOrder) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Geography/GeoCache.cs b/server/src/Core/Baya.Application/Features/Geography/GeoCache.cs new file mode 100644 index 0000000..bbdb612 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/GeoCache.cs @@ -0,0 +1,31 @@ +#nullable enable +using Baya.Application.Contracts.Common; + +namespace Baya.Application.Features.Geography; + +/// +/// 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. +/// +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 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"); +} diff --git a/server/src/Core/Baya.Application/Features/Geography/Queries/GetGeoTree/GetGeoTreeQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Geography/Queries/GetGeoTree/GetGeoTreeQuery.Handler.cs new file mode 100644 index 0000000..6363c64 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Queries/GetGeoTree/GetGeoTreeQuery.Handler.cs @@ -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>> +{ + public async ValueTask>> 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>.SuccessResult(tree); + } +} diff --git a/server/src/Core/Baya.Application/Features/Geography/Queries/GetGeoTree/GetGeoTreeQuery.cs b/server/src/Core/Baya.Application/Features/Geography/Queries/GetGeoTree/GetGeoTreeQuery.cs new file mode 100644 index 0000000..33d2402 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Queries/GetGeoTree/GetGeoTreeQuery.cs @@ -0,0 +1,9 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Geography; +using Mediator; + +namespace Baya.Application.Features.Geography.Queries.GetGeoTree; + +/// The full active province→city→district tree in one cached payload for a single-round-trip +/// cascading dropdown. Public. +public record GetGeoTreeQuery : IRequest>>; diff --git a/server/src/Core/Baya.Application/Features/Geography/Queries/ListCities/ListCitiesQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Geography/Queries/ListCities/ListCitiesQuery.Handler.cs new file mode 100644 index 0000000..58a8fc6 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Queries/ListCities/ListCitiesQuery.Handler.cs @@ -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>> +{ + public async ValueTask>> 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>.SuccessResult(cities); + } +} diff --git a/server/src/Core/Baya.Application/Features/Geography/Queries/ListCities/ListCitiesQuery.cs b/server/src/Core/Baya.Application/Features/Geography/Queries/ListCities/ListCitiesQuery.cs new file mode 100644 index 0000000..836074b --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Queries/ListCities/ListCitiesQuery.cs @@ -0,0 +1,8 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Geography; +using Mediator; + +namespace Baya.Application.Features.Geography.Queries.ListCities; + +/// Active cities under a province, ordered. Public. +public record ListCitiesQuery(long ProvinceId) : IRequest>>; diff --git a/server/src/Core/Baya.Application/Features/Geography/Queries/ListDistricts/ListDistrictsQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Geography/Queries/ListDistricts/ListDistrictsQuery.Handler.cs new file mode 100644 index 0000000..9cabfbe --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Queries/ListDistricts/ListDistrictsQuery.Handler.cs @@ -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>> +{ + public async ValueTask>> 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>.SuccessResult(districts); + } +} diff --git a/server/src/Core/Baya.Application/Features/Geography/Queries/ListDistricts/ListDistrictsQuery.cs b/server/src/Core/Baya.Application/Features/Geography/Queries/ListDistricts/ListDistrictsQuery.cs new file mode 100644 index 0000000..a0dd01a --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Queries/ListDistricts/ListDistrictsQuery.cs @@ -0,0 +1,9 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Geography; +using Mediator; + +namespace Baya.Application.Features.Geography.Queries.ListDistricts; + +/// 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. +public record ListDistrictsQuery(long CityId) : IRequest>>; diff --git a/server/src/Core/Baya.Application/Features/Geography/Queries/ListProvinces/ListProvincesQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Geography/Queries/ListProvinces/ListProvincesQuery.Handler.cs new file mode 100644 index 0000000..a225894 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Queries/ListProvinces/ListProvincesQuery.Handler.cs @@ -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>> +{ + public async ValueTask>> 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>.SuccessResult(provinces); + } +} diff --git a/server/src/Core/Baya.Application/Features/Geography/Queries/ListProvinces/ListProvincesQuery.cs b/server/src/Core/Baya.Application/Features/Geography/Queries/ListProvinces/ListProvincesQuery.cs new file mode 100644 index 0000000..d95c9d3 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Geography/Queries/ListProvinces/ListProvincesQuery.cs @@ -0,0 +1,8 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Geography; +using Mediator; + +namespace Baya.Application.Features.Geography.Queries.ListProvinces; + +/// Active provinces, ordered by sort order, for the top of the cascading dropdown. Public. +public record ListProvincesQuery : IRequest>>; diff --git a/server/src/Core/Baya.Application/Features/ServiceAreas/Commands/AddNurseServiceArea/AddNurseServiceAreaCommand.Handler.cs b/server/src/Core/Baya.Application/Features/ServiceAreas/Commands/AddNurseServiceArea/AddNurseServiceAreaCommand.Handler.cs new file mode 100644 index 0000000..74769b4 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/ServiceAreas/Commands/AddNurseServiceArea/AddNurseServiceAreaCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(AddNurseServiceAreaCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + if (currentUser.Roles?.Contains(RoleNames.Nurse) != true) + return OperationResult.ForbiddenResult("Only a nurse can declare a service area."); + + var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (nurseId is not { } nid) + return OperationResult.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.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.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.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.SuccessResult(new NurseServiceAreaDto( + area.Id, + city.Id, + city.NameFa, + city.NameEn, + area.DistrictId, + district?.NameFa, + district?.NameEn, + area.DistrictId is null, + area.IsActive)); + } +} diff --git a/server/src/Core/Baya.Application/Features/ServiceAreas/Commands/AddNurseServiceArea/AddNurseServiceAreaCommand.Validator.cs b/server/src/Core/Baya.Application/Features/ServiceAreas/Commands/AddNurseServiceArea/AddNurseServiceAreaCommand.Validator.cs new file mode 100644 index 0000000..5ac53f6 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/ServiceAreas/Commands/AddNurseServiceArea/AddNurseServiceAreaCommand.Validator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Baya.Application.Features.ServiceAreas.Commands.AddNurseServiceArea; + +public sealed class AddNurseServiceAreaCommandValidator : AbstractValidator +{ + public AddNurseServiceAreaCommandValidator() + { + RuleFor(x => x.CityId).GreaterThan(0); + RuleFor(x => x.DistrictId).GreaterThan(0).When(x => x.DistrictId.HasValue); + } +} diff --git a/server/src/Core/Baya.Application/Features/ServiceAreas/Commands/AddNurseServiceArea/AddNurseServiceAreaCommand.cs b/server/src/Core/Baya.Application/Features/ServiceAreas/Commands/AddNurseServiceArea/AddNurseServiceAreaCommand.cs new file mode 100644 index 0000000..68d488d --- /dev/null +++ b/server/src/Core/Baya.Application/Features/ServiceAreas/Commands/AddNurseServiceArea/AddNurseServiceAreaCommand.cs @@ -0,0 +1,13 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Geography; +using Mediator; + +namespace Baya.Application.Features.ServiceAreas.Commands.AddNurseServiceArea; + +/// +/// The signed-in nurse declares coverage. Omitting (null) means the whole +/// city — a deliberate coverage choice. The nurse is derived from the caller, never the body. A +/// duplicate (including a duplicate whole-city row) returns 409. +/// +public record AddNurseServiceAreaCommand(long CityId, long? DistrictId) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/ServiceAreas/Commands/RemoveNurseServiceArea/RemoveNurseServiceAreaCommand.Handler.cs b/server/src/Core/Baya.Application/Features/ServiceAreas/Commands/RemoveNurseServiceArea/RemoveNurseServiceAreaCommand.Handler.cs new file mode 100644 index 0000000..1f315a5 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/ServiceAreas/Commands/RemoveNurseServiceArea/RemoveNurseServiceAreaCommand.Handler.cs @@ -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> +{ + public async ValueTask> Handle(RemoveNurseServiceAreaCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + if (currentUser.Roles?.Contains(RoleNames.Nurse) != true) + return OperationResult.ForbiddenResult("Only a nurse can manage service areas."); + + var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (nurseId is not { } nid) + return OperationResult.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.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.SuccessResult(true); + } +} diff --git a/server/src/Core/Baya.Application/Features/ServiceAreas/Commands/RemoveNurseServiceArea/RemoveNurseServiceAreaCommand.cs b/server/src/Core/Baya.Application/Features/ServiceAreas/Commands/RemoveNurseServiceArea/RemoveNurseServiceAreaCommand.cs new file mode 100644 index 0000000..492a1c1 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/ServiceAreas/Commands/RemoveNurseServiceArea/RemoveNurseServiceAreaCommand.cs @@ -0,0 +1,7 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.ServiceAreas.Commands.RemoveNurseServiceArea; + +/// Soft-removes one of the signed-in nurse's own service areas (tenancy-checked). +public record RemoveNurseServiceAreaCommand(long Id) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/ServiceAreas/Queries/ListMyServiceAreas/ListMyServiceAreasQuery.Handler.cs b/server/src/Core/Baya.Application/Features/ServiceAreas/Queries/ListMyServiceAreas/ListMyServiceAreasQuery.Handler.cs new file mode 100644 index 0000000..e9099ed --- /dev/null +++ b/server/src/Core/Baya.Application/Features/ServiceAreas/Queries/ListMyServiceAreas/ListMyServiceAreasQuery.Handler.cs @@ -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>> +{ + public async ValueTask>> Handle(ListMyServiceAreasQuery request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult>.UnauthorizedResult("Not authenticated."); + + if (currentUser.Roles?.Contains(RoleNames.Nurse) != true) + return OperationResult>.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>.SuccessResult( + new PagedResult([], 0, page, pageSize)); + + var result = await unitOfWork.NurseServiceAreaRepository.ListAsync(nid, page, pageSize, cancellationToken); + return OperationResult>.SuccessResult(result); + } +} diff --git a/server/src/Core/Baya.Application/Features/ServiceAreas/Queries/ListMyServiceAreas/ListMyServiceAreasQuery.cs b/server/src/Core/Baya.Application/Features/ServiceAreas/Queries/ListMyServiceAreas/ListMyServiceAreasQuery.cs new file mode 100644 index 0000000..0ef16a9 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/ServiceAreas/Queries/ListMyServiceAreas/ListMyServiceAreasQuery.cs @@ -0,0 +1,9 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Geography; +using Mediator; + +namespace Baya.Application.Features.ServiceAreas.Queries.ListMyServiceAreas; + +/// The signed-in nurse's own service areas (tenancy-scoped, paginated, whole-city first). +public record ListMyServiceAreasQuery(int Page = 1, int PageSize = 50) + : IRequest>>; diff --git a/server/src/Core/Baya.Application/Models/Addresses/CustomerAddressDto.cs b/server/src/Core/Baya.Application/Models/Addresses/CustomerAddressDto.cs new file mode 100644 index 0000000..803ee77 --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Addresses/CustomerAddressDto.cs @@ -0,0 +1,24 @@ +namespace Baya.Application.Models.Addresses; + +/// +/// 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 and may be null when the address was +/// saved without a confident geocode. +/// +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); diff --git a/server/src/Core/Baya.Application/Models/ApiResult/ApiResultStatusCodecs.cs b/server/src/Core/Baya.Application/Models/ApiResult/ApiResultStatusCodecs.cs index 2cdaad5..a371faa 100644 --- a/server/src/Core/Baya.Application/Models/ApiResult/ApiResultStatusCodecs.cs +++ b/server/src/Core/Baya.Application/Models/ApiResult/ApiResultStatusCodecs.cs @@ -26,6 +26,9 @@ public enum ApiResultStatusCode [Display(Name = "Authorization Error")] Forbidden = 403, + [Display(Name = "Conflict")] + Conflict = 409, + [Display(Name = "Not Acceptable")] NotAcceptable = 406, diff --git a/server/src/Core/Baya.Application/Models/Common/OperationResult.cs b/server/src/Core/Baya.Application/Models/Common/OperationResult.cs index a0287cd..653b58e 100644 --- a/server/src/Core/Baya.Application/Models/Common/OperationResult.cs +++ b/server/src/Core/Baya.Application/Models/Common/OperationResult.cs @@ -26,6 +26,10 @@ public class OperationResult : IOperationResult /// Maps to HTTP 403 — e.g. self-assigning an internal admin role (backend-phase-2). public bool IsForbidden { get; set; } + /// 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. + public bool IsConflict { get; set; } + public static OperationResult SuccessResult(TResult result) { return new OperationResult { Result = result, IsSuccess = true }; @@ -74,6 +78,15 @@ public class OperationResult : IOperationResult return operationResult; } + public static OperationResult ConflictResult(string message) + { + var operationResult = new OperationResult { IsSuccess = false, IsConflict = true }; + + operationResult.ErrorMessages.Add(new("GeneralError", message)); + + return operationResult; + } + public void AddError(string propertyName, string message) { IsSuccess = false; diff --git a/server/src/Core/Baya.Application/Models/Geography/CityDto.cs b/server/src/Core/Baya.Application/Models/Geography/CityDto.cs new file mode 100644 index 0000000..3b79682 --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Geography/CityDto.cs @@ -0,0 +1,4 @@ +namespace Baya.Application.Models.Geography; + +/// A city option under a province for the cascading dropdown. +public record CityDto(long Id, long ProvinceId, string NameFa, string NameEn, int SortOrder); diff --git a/server/src/Core/Baya.Application/Models/Geography/CityTreeDto.cs b/server/src/Core/Baya.Application/Models/Geography/CityTreeDto.cs new file mode 100644 index 0000000..72a0062 --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Geography/CityTreeDto.cs @@ -0,0 +1,9 @@ +namespace Baya.Application.Models.Geography; + +/// A city node in the geo tree, carrying its active districts (empty when the city has none). +public record CityTreeDto( + long Id, + string NameFa, + string NameEn, + int SortOrder, + IReadOnlyList Districts); diff --git a/server/src/Core/Baya.Application/Models/Geography/DistrictDto.cs b/server/src/Core/Baya.Application/Models/Geography/DistrictDto.cs new file mode 100644 index 0000000..25ed9d9 --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Geography/DistrictDto.cs @@ -0,0 +1,5 @@ +namespace Baya.Application.Models.Geography; + +/// A district option under a city. An empty district list for a city is a valid result — the +/// caller then selects whole-city coverage. +public record DistrictDto(long Id, long CityId, string NameFa, string NameEn, int SortOrder); diff --git a/server/src/Core/Baya.Application/Models/Geography/NurseServiceAreaDto.cs b/server/src/Core/Baya.Application/Models/Geography/NurseServiceAreaDto.cs new file mode 100644 index 0000000..4adda57 --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Geography/NurseServiceAreaDto.cs @@ -0,0 +1,16 @@ +namespace Baya.Application.Models.Geography; + +/// +/// A nurse's declared coverage row. is true (and the district fields are +/// null) when district_id is NULL — meaning the entire city, a deliberate coverage choice. +/// +public record NurseServiceAreaDto( + long Id, + long CityId, + string CityNameFa, + string CityNameEn, + long? DistrictId, + string DistrictNameFa, + string DistrictNameEn, + bool IsWholeCity, + bool IsActive); diff --git a/server/src/Core/Baya.Application/Models/Geography/ProvinceDto.cs b/server/src/Core/Baya.Application/Models/Geography/ProvinceDto.cs new file mode 100644 index 0000000..64fc7c8 --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Geography/ProvinceDto.cs @@ -0,0 +1,4 @@ +namespace Baya.Application.Models.Geography; + +/// A province option for the cascading dropdown (active provinces, ordered by sort order). +public record ProvinceDto(long Id, string NameFa, string NameEn, int SortOrder); diff --git a/server/src/Core/Baya.Application/Models/Geography/ProvinceTreeDto.cs b/server/src/Core/Baya.Application/Models/Geography/ProvinceTreeDto.cs new file mode 100644 index 0000000..89f98d7 --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Geography/ProvinceTreeDto.cs @@ -0,0 +1,10 @@ +namespace Baya.Application.Models.Geography; + +/// A province node in the geo tree — the full active province→city→district hierarchy served in +/// one cached payload for the cascading dropdown. +public record ProvinceTreeDto( + long Id, + string NameFa, + string NameEn, + int SortOrder, + IReadOnlyList Cities); diff --git a/server/src/Core/Baya.Domain/Entities/Geography/City.cs b/server/src/Core/Baya.Domain/Entities/Geography/City.cs new file mode 100644 index 0000000..1cb313a --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Geography/City.cs @@ -0,0 +1,23 @@ +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Geography; + +/// +/// A city under a — 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. +/// +public class City : BaseEntity +{ + 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 Districts { get; set; } +} diff --git a/server/src/Core/Baya.Domain/Entities/Geography/District.cs b/server/src/Core/Baya.Domain/Entities/Geography/District.cs new file mode 100644 index 0000000..e9102bd --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Geography/District.cs @@ -0,0 +1,21 @@ +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Geography; + +/// +/// An optional subdivision of a — 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. +/// +public class District : BaseEntity +{ + 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; } +} diff --git a/server/src/Core/Baya.Domain/Entities/Geography/NurseServiceArea.cs b/server/src/Core/Baya.Domain/Entities/Geography/NurseServiceArea.cs new file mode 100644 index 0000000..c844d95 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Geography/NurseServiceArea.cs @@ -0,0 +1,26 @@ +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Geography; + +/// +/// Where a nurse will travel — the membership row search later intersects with a customer's address. +/// A row with == null is a meaningful "entire city" 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. +/// +public class NurseServiceArea : BaseEntity +{ + public long NurseId { get; set; } + + public long CityId { get; set; } + public City City { get; set; } + + /// NULL = the entire city (a deliberate coverage choice, not a forgotten selection). + public long? DistrictId { get; set; } + public District District { get; set; } + + public bool IsActive { get; set; } + + public DateTimeOffset? DeletedAt { get; set; } +} diff --git a/server/src/Core/Baya.Domain/Entities/Geography/Province.cs b/server/src/Core/Baya.Domain/Entities/Geography/Province.cs new file mode 100644 index 0000000..05ff1f8 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Geography/Province.cs @@ -0,0 +1,21 @@ +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Geography; + +/// +/// 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 / 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. +/// +public class Province : BaseEntity +{ + 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 Cities { get; set; } +} diff --git a/server/src/Core/Baya.Domain/Entities/Identity/CustomerAddress.cs b/server/src/Core/Baya.Domain/Entities/Identity/CustomerAddress.cs new file mode 100644 index 0000000..5ea3b5c --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Identity/CustomerAddress.cs @@ -0,0 +1,44 @@ +using Baya.Domain.Common; +using Baya.Domain.Entities.Geography; + +namespace Baya.Domain.Entities.Identity; + +/// +/// 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); / are +/// produced by the IGeocoder 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. +/// +public class CustomerAddress : BaseEntity +{ + 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; } + + /// A label ("خانه"/"محل کار") — not PII, stored plaintext. + public string Title { get; set; } + + /// Encrypted at rest. + public string AddressLine { get; set; } + + /// Encrypted at rest. + public string PostalCode { get; set; } + + public decimal? Latitude { get; set; } + public decimal? Longitude { get; set; } + + public bool IsPrimary { get; set; } + + /// Encrypted at rest. + public string RecipientName { get; set; } + + /// Encrypted at rest. + public string RecipientPhone { get; set; } + + public DateTimeOffset? DeletedAt { get; set; } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockGeocoder.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockGeocoder.cs new file mode 100644 index 0000000..a10b9c6 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockGeocoder.cs @@ -0,0 +1,87 @@ +#nullable enable +using System.Text; +using Baya.Application.Contracts.Common; +using Microsoft.Extensions.Options; + +namespace Baya.Infrastructure.CrossCutting.Seams; + +/// +/// Deterministic, network-free — 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. +/// +public sealed class MockGeocoder(IOptions 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 Centroids = + new Dictionary(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 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; + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs index d612d95..078da32 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs @@ -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(); +} + +/// +/// Tunes the mock IGeocoder. By default it resolves every address to a deterministic point around +/// the city centroid. Set to force the unresolved path globally, or +/// embed in a single address to exercise the "saved without a map pin" +/// UI state per-request. The real vendor implementation ignores these. +/// +public sealed class GeocodingOptions +{ + /// When true, every geocode returns null coordinates with low confidence. + public bool ReturnNullCoordinates { get; set; } + + /// An address whose text contains this marker resolves to null coordinates (testability). + public string LowConfidenceMarker { get; set; } = "NO_GEO"; + + /// Confidence returned for a successfully resolved address. + public double ResolvedConfidence { get; set; } = 0.9; } /// diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs index 58522a0..ed3a2f9 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs @@ -32,6 +32,10 @@ public static class ServiceCollectionExtension // fake match; a real Finnotech/banking-bridge client replaces this registration only. services.AddSingleton(); + // 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(); + return services; } } diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs index c73e48e..0f808cd 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs @@ -121,5 +121,15 @@ public class ApplicationDbContext: IdentityDbContext 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(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); + }); } } \ No newline at end of file diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/GeographyConfig/CityConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/GeographyConfig/CityConfig.cs new file mode 100644 index 0000000..b8eae1e --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/GeographyConfig/CityConfig.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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()); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/GeographyConfig/DistrictConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/GeographyConfig/DistrictConfig.cs new file mode 100644 index 0000000..8d1f364 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/GeographyConfig/DistrictConfig.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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()); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/GeographyConfig/GeographySeed.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/GeographyConfig/GeographySeed.cs new file mode 100644 index 0000000..7272a69 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/GeographyConfig/GeographySeed.cs @@ -0,0 +1,109 @@ +namespace Baya.Infrastructure.Persistence.Configuration.GeographyConfig; + +/// +/// The one-time province/city/district seed, loaded via HasData so the rows land with the +/// migration on a fresh DB (the b1 seeding path). Ids are fixed and deterministic — city id is +/// 100 + provinceId, Tehran's districts are 1001…1022 — 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. +/// +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'])); +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/GeographyConfig/NurseServiceAreaConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/GeographyConfig/NurseServiceAreaConfig.cs new file mode 100644 index 0000000..386bd35 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/GeographyConfig/NurseServiceAreaConfig.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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() + .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); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/GeographyConfig/ProvinceConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/GeographyConfig/ProvinceConfig.cs new file mode 100644 index 0000000..861bb9f --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/GeographyConfig/ProvinceConfig.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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()); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/IdentityConfig/CustomerAddressConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/IdentityConfig/CustomerAddressConfig.cs new file mode 100644 index 0000000..34a99e3 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/IdentityConfig/CustomerAddressConfig.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702093332_GeographyAddressesServiceAreas.Designer.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702093332_GeographyAddressesServiceAreas.Designer.cs new file mode 100644 index 0000000..3bed5f0 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702093332_GeographyAddressesServiceAreas.Designer.cs @@ -0,0 +1,2516 @@ +// +using System; +using Baya.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Baya.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260702093332_GeographyAddressesServiceAreas")] + partial class GeographyAddressesServiceAreas + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("PropsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("UserId"); + + b.ToTable("SystemEvents", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ActorUserId") + .HasColumnType("int"); + + b.Property("ChangedFieldsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("AuditLogs", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Configuration.PlatformConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("PlatformConfigs", "ops"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "Balinyaar commission rate on the booking gross (fraction).", + Key = "platform_fee_rate", + Value = "0.15" + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "VAT rate applied to the commission line only (fraction).", + Key = "vat_rate", + Value = "0.10" + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours after check-out a booking can be disputed.", + Key = "dispute_window_hours", + Value = "72" + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Minutes a family has to pay before a pending booking expires.", + Key = "booking_payment_deadline_minutes", + Value = "30" + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours a nurse has to accept/decline a booking request.", + Key = "nurse_response_deadline_hours", + Value = "24" + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Weekly payout cadence in days.", + Key = "nurse_payout_interval_days", + Value = "7" + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Allowed EVV check-in distance from the care address.", + Key = "evv_location_tolerance_meters", + Value = "200" + }, + new + { + Id = 8L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "A review at or below this rating raises a support alert.", + Key = "min_rating_for_support_alert", + Value = "2" + }, + new + { + Id = 9L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "string", + Description = "Who is merchant of record for BNPL orders (platform|nurse).", + Key = "bnpl_merchant_of_record", + Value = "platform" + }, + new + { + Id = 10L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "BNPL provider commission rate (fraction).", + Key = "bnpl_provider_commission_rate", + Value = "0.07" + }, + new + { + Id = 11L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "string", + Description = "When BNPL settles funds to the platform (immediate|deferred).", + Key = "bnpl_settlement_timing", + Value = "immediate" + }, + new + { + Id = 12L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "json", + Description = "Tiered cancellation refund policy: refund_percent by hours before the visit.", + Key = "cancellation_tiers", + Value = "[{\"min_hours_before\":48,\"refund_percent\":100},{\"min_hours_before\":24,\"refund_percent\":50},{\"min_hours_before\":0,\"refund_percent\":0}]" + }, + new + { + Id = 13L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Seconds a phone must wait before another OTP can be requested.", + Key = "auth_otp_resend_seconds", + Value = "120" + }, + new + { + Id = 14L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Wrong-code attempts allowed before OTP verification is refused until a fresh code.", + Key = "auth_otp_max_attempts", + Value = "5" + }, + new + { + Id = 15L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Refresh-token session lifetime in days.", + Key = "auth_session_ttl_days", + Value = "30" + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ProvinceId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("ProvinceId", "SortOrder"); + + b.ToTable("Cities", "geo"); + + b.HasData( + new + { + Id = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tehran", + NameFa = "تهران", + ProvinceId = 1L, + SortOrder = 1 + }, + new + { + Id = 102L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Karaj", + NameFa = "کرج", + ProvinceId = 2L, + SortOrder = 1 + }, + new + { + Id = 103L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Isfahan", + NameFa = "اصفهان", + ProvinceId = 3L, + SortOrder = 1 + }, + new + { + Id = 104L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Shiraz", + NameFa = "شیراز", + ProvinceId = 4L, + SortOrder = 1 + }, + new + { + Id = 105L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Mashhad", + NameFa = "مشهد", + ProvinceId = 5L, + SortOrder = 1 + }, + new + { + Id = 106L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tabriz", + NameFa = "تبریز", + ProvinceId = 6L, + SortOrder = 1 + }, + new + { + Id = 107L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Urmia", + NameFa = "ارومیه", + ProvinceId = 7L, + SortOrder = 1 + }, + new + { + Id = 108L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ahvaz", + NameFa = "اهواز", + ProvinceId = 8L, + SortOrder = 1 + }, + new + { + Id = 109L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qom", + NameFa = "قم", + ProvinceId = 9L, + SortOrder = 1 + }, + new + { + Id = 110L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kerman", + NameFa = "کرمان", + ProvinceId = 10L, + SortOrder = 1 + }, + new + { + Id = 111L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Rasht", + NameFa = "رشت", + ProvinceId = 11L, + SortOrder = 1 + }, + new + { + Id = 112L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sari", + NameFa = "ساری", + ProvinceId = 12L, + SortOrder = 1 + }, + new + { + Id = 113L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Arak", + NameFa = "اراک", + ProvinceId = 13L, + SortOrder = 1 + }, + new + { + Id = 114L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ardabil", + NameFa = "اردبیل", + ProvinceId = 14L, + SortOrder = 1 + }, + new + { + Id = 115L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qazvin", + NameFa = "قزوین", + ProvinceId = 15L, + SortOrder = 1 + }, + new + { + Id = 116L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kermanshah", + NameFa = "کرمانشاه", + ProvinceId = 16L, + SortOrder = 1 + }, + new + { + Id = 117L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bojnord", + NameFa = "بجنورد", + ProvinceId = 17L, + SortOrder = 1 + }, + new + { + Id = 118L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Birjand", + NameFa = "بیرجند", + ProvinceId = 18L, + SortOrder = 1 + }, + new + { + Id = 119L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hamadan", + NameFa = "همدان", + ProvinceId = 19L, + SortOrder = 1 + }, + new + { + Id = 120L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sanandaj", + NameFa = "سنندج", + ProvinceId = 20L, + SortOrder = 1 + }, + new + { + Id = 121L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Khorramabad", + NameFa = "خرم‌آباد", + ProvinceId = 21L, + SortOrder = 1 + }, + new + { + Id = 122L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Gorgan", + NameFa = "گرگان", + ProvinceId = 22L, + SortOrder = 1 + }, + new + { + Id = 123L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bandar Abbas", + NameFa = "بندرعباس", + ProvinceId = 23L, + SortOrder = 1 + }, + new + { + Id = 124L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bushehr", + NameFa = "بوشهر", + ProvinceId = 24L, + SortOrder = 1 + }, + new + { + Id = 125L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zanjan", + NameFa = "زنجان", + ProvinceId = 25L, + SortOrder = 1 + }, + new + { + Id = 126L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Semnan", + NameFa = "سمنان", + ProvinceId = 26L, + SortOrder = 1 + }, + new + { + Id = 127L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yazd", + NameFa = "یزد", + ProvinceId = 27L, + SortOrder = 1 + }, + new + { + Id = 128L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zahedan", + NameFa = "زاهدان", + ProvinceId = 28L, + SortOrder = 1 + }, + new + { + Id = 129L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Shahrekord", + NameFa = "شهرکرد", + ProvinceId = 29L, + SortOrder = 1 + }, + new + { + Id = 130L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yasuj", + NameFa = "یاسوج", + ProvinceId = 30L, + SortOrder = 1 + }, + new + { + Id = 131L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ilam", + NameFa = "ایلام", + ProvinceId = 31L, + SortOrder = 1 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.District", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("CityId", "SortOrder"); + + b.ToTable("Districts", "geo"); + + b.HasData( + new + { + Id = 1001L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 1", + NameFa = "منطقه ۱", + SortOrder = 1 + }, + new + { + Id = 1002L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 2", + NameFa = "منطقه ۲", + SortOrder = 2 + }, + new + { + Id = 1003L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 3", + NameFa = "منطقه ۳", + SortOrder = 3 + }, + new + { + Id = 1004L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 4", + NameFa = "منطقه ۴", + SortOrder = 4 + }, + new + { + Id = 1005L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 5", + NameFa = "منطقه ۵", + SortOrder = 5 + }, + new + { + Id = 1006L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 6", + NameFa = "منطقه ۶", + SortOrder = 6 + }, + new + { + Id = 1007L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 7", + NameFa = "منطقه ۷", + SortOrder = 7 + }, + new + { + Id = 1008L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 8", + NameFa = "منطقه ۸", + SortOrder = 8 + }, + new + { + Id = 1009L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 9", + NameFa = "منطقه ۹", + SortOrder = 9 + }, + new + { + Id = 1010L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 10", + NameFa = "منطقه ۱۰", + SortOrder = 10 + }, + new + { + Id = 1011L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 11", + NameFa = "منطقه ۱۱", + SortOrder = 11 + }, + new + { + Id = 1012L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 12", + NameFa = "منطقه ۱۲", + SortOrder = 12 + }, + new + { + Id = 1013L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 13", + NameFa = "منطقه ۱۳", + SortOrder = 13 + }, + new + { + Id = 1014L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 14", + NameFa = "منطقه ۱۴", + SortOrder = 14 + }, + new + { + Id = 1015L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 15", + NameFa = "منطقه ۱۵", + SortOrder = 15 + }, + new + { + Id = 1016L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 16", + NameFa = "منطقه ۱۶", + SortOrder = 16 + }, + new + { + Id = 1017L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 17", + NameFa = "منطقه ۱۷", + SortOrder = 17 + }, + new + { + Id = 1018L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 18", + NameFa = "منطقه ۱۸", + SortOrder = 18 + }, + new + { + Id = 1019L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 19", + NameFa = "منطقه ۱۹", + SortOrder = 19 + }, + new + { + Id = 1020L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 20", + NameFa = "منطقه ۲۰", + SortOrder = 20 + }, + new + { + Id = 1021L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 21", + NameFa = "منطقه ۲۱", + SortOrder = 21 + }, + new + { + Id = 1022L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 22", + NameFa = "منطقه ۲۲", + SortOrder = 22 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.NurseServiceArea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DistrictId") + .HasColumnType("bigint"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("DistrictId"); + + b.HasIndex("NurseId", "CityId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceAreas_Nurse_City_WholeCity") + .HasFilter("[DistrictId] IS NULL AND [DeletedAt] IS NULL"); + + b.HasIndex("NurseId", "CityId", "DistrictId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceAreas_Nurse_City_District") + .HasFilter("[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL"); + + b.ToTable("NurseServiceAreas", "geo"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.Province", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("Provinces", "geo"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tehran", + NameFa = "تهران", + SortOrder = 1 + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Alborz", + NameFa = "البرز", + SortOrder = 2 + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Isfahan", + NameFa = "اصفهان", + SortOrder = 3 + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Fars", + NameFa = "فارس", + SortOrder = 4 + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Razavi Khorasan", + NameFa = "خراسان رضوی", + SortOrder = 5 + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "East Azerbaijan", + NameFa = "آذربایجان شرقی", + SortOrder = 6 + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "West Azerbaijan", + NameFa = "آذربایجان غربی", + SortOrder = 7 + }, + new + { + Id = 8L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Khuzestan", + NameFa = "خوزستان", + SortOrder = 8 + }, + new + { + Id = 9L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qom", + NameFa = "قم", + SortOrder = 9 + }, + new + { + Id = 10L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kerman", + NameFa = "کرمان", + SortOrder = 10 + }, + new + { + Id = 11L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Gilan", + NameFa = "گیلان", + SortOrder = 11 + }, + new + { + Id = 12L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Mazandaran", + NameFa = "مازندران", + SortOrder = 12 + }, + new + { + Id = 13L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Markazi", + NameFa = "مرکزی", + SortOrder = 13 + }, + new + { + Id = 14L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ardabil", + NameFa = "اردبیل", + SortOrder = 14 + }, + new + { + Id = 15L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qazvin", + NameFa = "قزوین", + SortOrder = 15 + }, + new + { + Id = 16L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kermanshah", + NameFa = "کرمانشاه", + SortOrder = 16 + }, + new + { + Id = 17L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "North Khorasan", + NameFa = "خراسان شمالی", + SortOrder = 17 + }, + new + { + Id = 18L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "South Khorasan", + NameFa = "خراسان جنوبی", + SortOrder = 18 + }, + new + { + Id = 19L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hamadan", + NameFa = "همدان", + SortOrder = 19 + }, + new + { + Id = 20L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kurdistan", + NameFa = "کردستان", + SortOrder = 20 + }, + new + { + Id = 21L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Lorestan", + NameFa = "لرستان", + SortOrder = 21 + }, + new + { + Id = 22L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Golestan", + NameFa = "گلستان", + SortOrder = 22 + }, + new + { + Id = 23L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hormozgan", + NameFa = "هرمزگان", + SortOrder = 23 + }, + new + { + Id = 24L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bushehr", + NameFa = "بوشهر", + SortOrder = 24 + }, + new + { + Id = 25L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zanjan", + NameFa = "زنجان", + SortOrder = 25 + }, + new + { + Id = 26L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Semnan", + NameFa = "سمنان", + SortOrder = 26 + }, + new + { + Id = 27L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yazd", + NameFa = "یزد", + SortOrder = 27 + }, + new + { + Id = 28L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sistan and Baluchestan", + NameFa = "سیستان و بلوچستان", + SortOrder = 28 + }, + new + { + Id = 29L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Chaharmahal and Bakhtiari", + NameFa = "چهارمحال و بختیاری", + SortOrder = 29 + }, + new + { + Id = 30L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kohgiluyeh and Boyer-Ahmad", + NameFa = "کهگیلویه و بویراحمد", + SortOrder = 30 + }, + new + { + Id = 31L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ilam", + NameFa = "ایلام", + SortOrder = 31 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Holidays.IranianHoliday", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("HolidayDate") + .HasColumnType("date"); + + b.Property("IsBankClosed") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("HolidayDate") + .IsUnique(); + + b.ToTable("IranianHolidays", "ops"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 2, 11), + IsBankClosed = true, + NameFa = "پیروزی انقلاب اسلامی", + Type = "national" + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 21), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 22), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 23), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 24), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 4, 1), + IsBankClosed = true, + NameFa = "روز طبیعت (سیزده‌به‌در)", + Type = "official" + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 6, 26), + IsBankClosed = true, + NameFa = "عید سعید قربان", + Type = "religious" + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AddressLine") + .HasColumnType("nvarchar(max)"); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DistrictId") + .HasColumnType("bigint"); + + b.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("Latitude") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("Longitude") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PostalCode") + .HasColumnType("nvarchar(max)"); + + b.Property("RecipientName") + .HasColumnType("nvarchar(max)"); + + b.Property("RecipientPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("CustomerId") + .IsUnique() + .HasDatabaseName("UX_CustomerAddresses_Customer_Primary") + .HasFilter("[IsPrimary] = 1 AND [DeletedAt] IS NULL"); + + b.HasIndex("DistrictId"); + + b.ToTable("CustomerAddresses", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DefaultEmergencyContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("DefaultEmergencyContactPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("CustomerProfiles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseBankAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountHolderFromBank") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("AccountHolderName") + .HasColumnType("nvarchar(max)"); + + b.Property("BankName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Iban") + .HasColumnType("nvarchar(max)"); + + b.Property("IbanHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsVerified") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("MatchedNationalId") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("OwnershipVendorRef") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("VerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("VerifiedByAdminId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("IbanHash") + .IsUnique(); + + b.HasIndex("NurseId") + .IsUnique() + .HasDatabaseName("UX_NurseBankAccounts_NurseId_Primary") + .HasFilter("[IsPrimary] = 1"); + + b.HasIndex("VerifiedByAdminId"); + + b.ToTable("NurseBankAccounts", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AverageRating") + .ValueGeneratedOnAdd() + .HasPrecision(3, 2) + .HasColumnType("decimal(3,2)") + .HasDefaultValue(0m); + + b.Property("Bio") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("EducationField") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("EducationLevel") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsAcceptingBookings") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsVerified") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PartnerCenterId") + .HasColumnType("bigint"); + + b.Property("SpecializationsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("TotalCompletedBookings") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("TotalReviews") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("YearsOfExperience") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("NurseProfiles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.Patient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BirthDate") + .HasColumnType("date"); + + b.Property("BloodType") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("FirstName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Gender") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("InitialMedicalNotes") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("LastName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.ToTable("Patients", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Body") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DataJson") + .HasColumnType("nvarchar(max)"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ReadAt") + .HasColumnType("datetimeoffset"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsRead", "CreatedAt"); + + b.ToTable("Notifications", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OwnerUserId") + .HasColumnType("int"); + + b.Property("ResolutionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("ResolvedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ReviewId") + .HasColumnType("bigint"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("Status"); + + b.HasIndex("Type"); + + b.ToTable("SupportAlerts", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Roles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.RoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedClaim") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("UserId"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FamilyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("GeneratedCode") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalId") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalIdVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("PhoneVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("ShahkarVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.HasIndex("PhoneHash") + .IsUnique() + .HasFilter("[PhoneHash] IS NOT NULL"); + + b.ToTable("Users", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("LoggedOn") + .HasColumnType("datetime2"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("IsValid") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserRefreshTokens", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRole", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.Property("CreatedUserRoleDate") + .HasColumnType("datetime2"); + + b.Property("GrantedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GrantedById") + .HasColumnType("int"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("GrantedById"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeviceInfo") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsRevoked") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("RefreshTokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RefreshTokenHash") + .IsUnique(); + + b.HasIndex("UserId", "IsRevoked"); + + b.ToTable("UserSessions", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserToken", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("GeneratedTime") + .HasColumnType("datetime2"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Audit.AuditLog", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("ActorUserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.HasOne("Baya.Domain.Entities.Geography.Province", "Province") + .WithMany("Cities") + .HasForeignKey("ProvinceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Province"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.District", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany("Districts") + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("City"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.NurseServiceArea", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany() + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Geography.District", "District") + .WithMany() + .HasForeignKey("DistrictId"); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("City"); + + b.Navigation("District"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerAddress", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany() + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Geography.District", "District") + .WithMany() + .HasForeignKey("DistrictId"); + + b.Navigation("City"); + + b.Navigation("Customer"); + + b.Navigation("District"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithOne() + .HasForeignKey("Baya.Domain.Entities.Identity.CustomerProfile", "UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseBankAccount", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany("BankAccounts") + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("VerifiedByAdminId"); + + b.Navigation("Nurse"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithOne() + .HasForeignKey("Baya.Domain.Entities.Identity.NurseProfile", "UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.Patient", b => + { + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer") + .WithMany("Patients") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("OwnerUserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.RoleClaim", b => + { + b.HasOne("Baya.Domain.Entities.User.Role", "Role") + .WithMany("Claims") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserClaim", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Claims") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserLogin", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Logins") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRefreshToken", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("UserRefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRole", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("GrantedById"); + + b.HasOne("Baya.Domain.Entities.User.Role", "Role") + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserSession", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Sessions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserToken", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Tokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.Navigation("Districts"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.Province", b => + { + b.Navigation("Cities"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b => + { + b.Navigation("Patients"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b => + { + b.Navigation("BankAccounts"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.Role", b => + { + b.Navigation("Claims"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.User", b => + { + b.Navigation("Claims"); + + b.Navigation("Logins"); + + b.Navigation("Sessions"); + + b.Navigation("Tokens"); + + b.Navigation("UserRefreshTokens"); + + b.Navigation("UserRoles"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702093332_GeographyAddressesServiceAreas.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702093332_GeographyAddressesServiceAreas.cs new file mode 100644 index 0000000..50dd682 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702093332_GeographyAddressesServiceAreas.cs @@ -0,0 +1,391 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace Baya.Infrastructure.Persistence.Migrations +{ + /// + public partial class GeographyAddressesServiceAreas : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "geo"); + + migrationBuilder.CreateTable( + name: "Provinces", + schema: "geo", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + NameFa = table.Column(type: "nvarchar(150)", maxLength: 150, nullable: false), + NameEn = table.Column(type: "nvarchar(150)", maxLength: 150, nullable: false), + SortOrder = table.Column(type: "int", nullable: false, defaultValue: 0), + IsActive = table.Column(type: "bit", nullable: false, defaultValue: true), + DeletedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + ModifiedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedById = table.Column(type: "int", nullable: true), + ModifiedById = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Provinces", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Cities", + schema: "geo", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ProvinceId = table.Column(type: "bigint", nullable: false), + NameFa = table.Column(type: "nvarchar(150)", maxLength: 150, nullable: false), + NameEn = table.Column(type: "nvarchar(150)", maxLength: 150, nullable: false), + SortOrder = table.Column(type: "int", nullable: false, defaultValue: 0), + IsActive = table.Column(type: "bit", nullable: false, defaultValue: true), + DeletedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + ModifiedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedById = table.Column(type: "int", nullable: true), + ModifiedById = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Cities", x => x.Id); + table.ForeignKey( + name: "FK_Cities_Provinces_ProvinceId", + column: x => x.ProvinceId, + principalSchema: "geo", + principalTable: "Provinces", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "Districts", + schema: "geo", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + CityId = table.Column(type: "bigint", nullable: false), + NameFa = table.Column(type: "nvarchar(150)", maxLength: 150, nullable: false), + NameEn = table.Column(type: "nvarchar(150)", maxLength: 150, nullable: false), + SortOrder = table.Column(type: "int", nullable: false, defaultValue: 0), + IsActive = table.Column(type: "bit", nullable: false, defaultValue: true), + DeletedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + ModifiedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedById = table.Column(type: "int", nullable: true), + ModifiedById = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Districts", x => x.Id); + table.ForeignKey( + name: "FK_Districts_Cities_CityId", + column: x => x.CityId, + principalSchema: "geo", + principalTable: "Cities", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "CustomerAddresses", + schema: "usr", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + CustomerId = table.Column(type: "bigint", nullable: false), + CityId = table.Column(type: "bigint", nullable: false), + DistrictId = table.Column(type: "bigint", nullable: true), + Title = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + AddressLine = table.Column(type: "nvarchar(max)", nullable: true), + PostalCode = table.Column(type: "nvarchar(max)", nullable: true), + Latitude = table.Column(type: "decimal(9,6)", precision: 9, scale: 6, nullable: true), + Longitude = table.Column(type: "decimal(9,6)", precision: 9, scale: 6, nullable: true), + IsPrimary = table.Column(type: "bit", nullable: false, defaultValue: false), + RecipientName = table.Column(type: "nvarchar(max)", nullable: true), + RecipientPhone = table.Column(type: "nvarchar(max)", nullable: true), + DeletedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + ModifiedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedById = table.Column(type: "int", nullable: true), + ModifiedById = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_CustomerAddresses", x => x.Id); + table.ForeignKey( + name: "FK_CustomerAddresses_Cities_CityId", + column: x => x.CityId, + principalSchema: "geo", + principalTable: "Cities", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_CustomerAddresses_CustomerProfiles_CustomerId", + column: x => x.CustomerId, + principalSchema: "usr", + principalTable: "CustomerProfiles", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_CustomerAddresses_Districts_DistrictId", + column: x => x.DistrictId, + principalSchema: "geo", + principalTable: "Districts", + principalColumn: "Id"); + }); + + migrationBuilder.CreateTable( + name: "NurseServiceAreas", + schema: "geo", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + NurseId = table.Column(type: "bigint", nullable: false), + CityId = table.Column(type: "bigint", nullable: false), + DistrictId = table.Column(type: "bigint", nullable: true), + IsActive = table.Column(type: "bit", nullable: false, defaultValue: true), + DeletedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + ModifiedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedById = table.Column(type: "int", nullable: true), + ModifiedById = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_NurseServiceAreas", x => x.Id); + table.ForeignKey( + name: "FK_NurseServiceAreas_Cities_CityId", + column: x => x.CityId, + principalSchema: "geo", + principalTable: "Cities", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_NurseServiceAreas_Districts_DistrictId", + column: x => x.DistrictId, + principalSchema: "geo", + principalTable: "Districts", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_NurseServiceAreas_NurseProfiles_NurseId", + column: x => x.NurseId, + principalSchema: "usr", + principalTable: "NurseProfiles", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.InsertData( + schema: "geo", + table: "Provinces", + columns: new[] { "Id", "CreatedAt", "CreatedById", "DeletedAt", "IsActive", "ModifiedAt", "ModifiedById", "NameEn", "NameFa", "SortOrder" }, + values: new object[,] + { + { 1L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Tehran", "تهران", 1 }, + { 2L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Alborz", "البرز", 2 }, + { 3L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Isfahan", "اصفهان", 3 }, + { 4L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Fars", "فارس", 4 }, + { 5L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Razavi Khorasan", "خراسان رضوی", 5 }, + { 6L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "East Azerbaijan", "آذربایجان شرقی", 6 }, + { 7L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "West Azerbaijan", "آذربایجان غربی", 7 }, + { 8L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Khuzestan", "خوزستان", 8 }, + { 9L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Qom", "قم", 9 }, + { 10L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Kerman", "کرمان", 10 }, + { 11L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Gilan", "گیلان", 11 }, + { 12L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Mazandaran", "مازندران", 12 }, + { 13L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Markazi", "مرکزی", 13 }, + { 14L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Ardabil", "اردبیل", 14 }, + { 15L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Qazvin", "قزوین", 15 }, + { 16L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Kermanshah", "کرمانشاه", 16 }, + { 17L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "North Khorasan", "خراسان شمالی", 17 }, + { 18L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "South Khorasan", "خراسان جنوبی", 18 }, + { 19L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Hamadan", "همدان", 19 }, + { 20L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Kurdistan", "کردستان", 20 }, + { 21L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Lorestan", "لرستان", 21 }, + { 22L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Golestan", "گلستان", 22 }, + { 23L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Hormozgan", "هرمزگان", 23 }, + { 24L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Bushehr", "بوشهر", 24 }, + { 25L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Zanjan", "زنجان", 25 }, + { 26L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Semnan", "سمنان", 26 }, + { 27L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Yazd", "یزد", 27 }, + { 28L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Sistan and Baluchestan", "سیستان و بلوچستان", 28 }, + { 29L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Chaharmahal and Bakhtiari", "چهارمحال و بختیاری", 29 }, + { 30L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Kohgiluyeh and Boyer-Ahmad", "کهگیلویه و بویراحمد", 30 }, + { 31L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Ilam", "ایلام", 31 } + }); + + migrationBuilder.InsertData( + schema: "geo", + table: "Cities", + columns: new[] { "Id", "CreatedAt", "CreatedById", "DeletedAt", "IsActive", "ModifiedAt", "ModifiedById", "NameEn", "NameFa", "ProvinceId", "SortOrder" }, + values: new object[,] + { + { 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Tehran", "تهران", 1L, 1 }, + { 102L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Karaj", "کرج", 2L, 1 }, + { 103L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Isfahan", "اصفهان", 3L, 1 }, + { 104L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Shiraz", "شیراز", 4L, 1 }, + { 105L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Mashhad", "مشهد", 5L, 1 }, + { 106L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Tabriz", "تبریز", 6L, 1 }, + { 107L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Urmia", "ارومیه", 7L, 1 }, + { 108L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Ahvaz", "اهواز", 8L, 1 }, + { 109L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Qom", "قم", 9L, 1 }, + { 110L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Kerman", "کرمان", 10L, 1 }, + { 111L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Rasht", "رشت", 11L, 1 }, + { 112L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Sari", "ساری", 12L, 1 }, + { 113L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Arak", "اراک", 13L, 1 }, + { 114L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Ardabil", "اردبیل", 14L, 1 }, + { 115L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Qazvin", "قزوین", 15L, 1 }, + { 116L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Kermanshah", "کرمانشاه", 16L, 1 }, + { 117L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Bojnord", "بجنورد", 17L, 1 }, + { 118L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Birjand", "بیرجند", 18L, 1 }, + { 119L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Hamadan", "همدان", 19L, 1 }, + { 120L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Sanandaj", "سنندج", 20L, 1 }, + { 121L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Khorramabad", "خرم‌آباد", 21L, 1 }, + { 122L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Gorgan", "گرگان", 22L, 1 }, + { 123L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Bandar Abbas", "بندرعباس", 23L, 1 }, + { 124L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Bushehr", "بوشهر", 24L, 1 }, + { 125L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Zanjan", "زنجان", 25L, 1 }, + { 126L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Semnan", "سمنان", 26L, 1 }, + { 127L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Yazd", "یزد", 27L, 1 }, + { 128L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Zahedan", "زاهدان", 28L, 1 }, + { 129L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Shahrekord", "شهرکرد", 29L, 1 }, + { 130L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Yasuj", "یاسوج", 30L, 1 }, + { 131L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "Ilam", "ایلام", 31L, 1 } + }); + + migrationBuilder.InsertData( + schema: "geo", + table: "Districts", + columns: new[] { "Id", "CityId", "CreatedAt", "CreatedById", "DeletedAt", "IsActive", "ModifiedAt", "ModifiedById", "NameEn", "NameFa", "SortOrder" }, + values: new object[,] + { + { 1001L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 1", "منطقه ۱", 1 }, + { 1002L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 2", "منطقه ۲", 2 }, + { 1003L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 3", "منطقه ۳", 3 }, + { 1004L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 4", "منطقه ۴", 4 }, + { 1005L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 5", "منطقه ۵", 5 }, + { 1006L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 6", "منطقه ۶", 6 }, + { 1007L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 7", "منطقه ۷", 7 }, + { 1008L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 8", "منطقه ۸", 8 }, + { 1009L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 9", "منطقه ۹", 9 }, + { 1010L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 10", "منطقه ۱۰", 10 }, + { 1011L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 11", "منطقه ۱۱", 11 }, + { 1012L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 12", "منطقه ۱۲", 12 }, + { 1013L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 13", "منطقه ۱۳", 13 }, + { 1014L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 14", "منطقه ۱۴", 14 }, + { 1015L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 15", "منطقه ۱۵", 15 }, + { 1016L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 16", "منطقه ۱۶", 16 }, + { 1017L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 17", "منطقه ۱۷", 17 }, + { 1018L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 18", "منطقه ۱۸", 18 }, + { 1019L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 19", "منطقه ۱۹", 19 }, + { 1020L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 20", "منطقه ۲۰", 20 }, + { 1021L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 21", "منطقه ۲۱", 21 }, + { 1022L, 101L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, true, null, null, "District 22", "منطقه ۲۲", 22 } + }); + + migrationBuilder.CreateIndex( + name: "IX_Cities_ProvinceId_SortOrder", + schema: "geo", + table: "Cities", + columns: new[] { "ProvinceId", "SortOrder" }); + + migrationBuilder.CreateIndex( + name: "IX_CustomerAddresses_CityId", + schema: "usr", + table: "CustomerAddresses", + column: "CityId"); + + migrationBuilder.CreateIndex( + name: "IX_CustomerAddresses_DistrictId", + schema: "usr", + table: "CustomerAddresses", + column: "DistrictId"); + + migrationBuilder.CreateIndex( + name: "UX_CustomerAddresses_Customer_Primary", + schema: "usr", + table: "CustomerAddresses", + column: "CustomerId", + unique: true, + filter: "[IsPrimary] = 1 AND [DeletedAt] IS NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Districts_CityId_SortOrder", + schema: "geo", + table: "Districts", + columns: new[] { "CityId", "SortOrder" }); + + migrationBuilder.CreateIndex( + name: "IX_NurseServiceAreas_CityId", + schema: "geo", + table: "NurseServiceAreas", + column: "CityId"); + + migrationBuilder.CreateIndex( + name: "IX_NurseServiceAreas_DistrictId", + schema: "geo", + table: "NurseServiceAreas", + column: "DistrictId"); + + migrationBuilder.CreateIndex( + name: "UX_NurseServiceAreas_Nurse_City_District", + schema: "geo", + table: "NurseServiceAreas", + columns: new[] { "NurseId", "CityId", "DistrictId" }, + unique: true, + filter: "[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL"); + + migrationBuilder.CreateIndex( + name: "UX_NurseServiceAreas_Nurse_City_WholeCity", + schema: "geo", + table: "NurseServiceAreas", + columns: new[] { "NurseId", "CityId" }, + unique: true, + filter: "[DistrictId] IS NULL AND [DeletedAt] IS NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Provinces_SortOrder", + schema: "geo", + table: "Provinces", + column: "SortOrder"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CustomerAddresses", + schema: "usr"); + + migrationBuilder.DropTable( + name: "NurseServiceAreas", + schema: "geo"); + + migrationBuilder.DropTable( + name: "Districts", + schema: "geo"); + + migrationBuilder.DropTable( + name: "Cities", + schema: "geo"); + + migrationBuilder.DropTable( + name: "Provinces", + schema: "geo"); + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs index cde6304..93558c7 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -281,6 +281,1033 @@ namespace Baya.Infrastructure.Persistence.Migrations }); }); + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ProvinceId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("ProvinceId", "SortOrder"); + + b.ToTable("Cities", "geo"); + + b.HasData( + new + { + Id = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tehran", + NameFa = "تهران", + ProvinceId = 1L, + SortOrder = 1 + }, + new + { + Id = 102L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Karaj", + NameFa = "کرج", + ProvinceId = 2L, + SortOrder = 1 + }, + new + { + Id = 103L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Isfahan", + NameFa = "اصفهان", + ProvinceId = 3L, + SortOrder = 1 + }, + new + { + Id = 104L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Shiraz", + NameFa = "شیراز", + ProvinceId = 4L, + SortOrder = 1 + }, + new + { + Id = 105L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Mashhad", + NameFa = "مشهد", + ProvinceId = 5L, + SortOrder = 1 + }, + new + { + Id = 106L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tabriz", + NameFa = "تبریز", + ProvinceId = 6L, + SortOrder = 1 + }, + new + { + Id = 107L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Urmia", + NameFa = "ارومیه", + ProvinceId = 7L, + SortOrder = 1 + }, + new + { + Id = 108L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ahvaz", + NameFa = "اهواز", + ProvinceId = 8L, + SortOrder = 1 + }, + new + { + Id = 109L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qom", + NameFa = "قم", + ProvinceId = 9L, + SortOrder = 1 + }, + new + { + Id = 110L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kerman", + NameFa = "کرمان", + ProvinceId = 10L, + SortOrder = 1 + }, + new + { + Id = 111L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Rasht", + NameFa = "رشت", + ProvinceId = 11L, + SortOrder = 1 + }, + new + { + Id = 112L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sari", + NameFa = "ساری", + ProvinceId = 12L, + SortOrder = 1 + }, + new + { + Id = 113L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Arak", + NameFa = "اراک", + ProvinceId = 13L, + SortOrder = 1 + }, + new + { + Id = 114L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ardabil", + NameFa = "اردبیل", + ProvinceId = 14L, + SortOrder = 1 + }, + new + { + Id = 115L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qazvin", + NameFa = "قزوین", + ProvinceId = 15L, + SortOrder = 1 + }, + new + { + Id = 116L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kermanshah", + NameFa = "کرمانشاه", + ProvinceId = 16L, + SortOrder = 1 + }, + new + { + Id = 117L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bojnord", + NameFa = "بجنورد", + ProvinceId = 17L, + SortOrder = 1 + }, + new + { + Id = 118L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Birjand", + NameFa = "بیرجند", + ProvinceId = 18L, + SortOrder = 1 + }, + new + { + Id = 119L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hamadan", + NameFa = "همدان", + ProvinceId = 19L, + SortOrder = 1 + }, + new + { + Id = 120L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sanandaj", + NameFa = "سنندج", + ProvinceId = 20L, + SortOrder = 1 + }, + new + { + Id = 121L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Khorramabad", + NameFa = "خرم‌آباد", + ProvinceId = 21L, + SortOrder = 1 + }, + new + { + Id = 122L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Gorgan", + NameFa = "گرگان", + ProvinceId = 22L, + SortOrder = 1 + }, + new + { + Id = 123L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bandar Abbas", + NameFa = "بندرعباس", + ProvinceId = 23L, + SortOrder = 1 + }, + new + { + Id = 124L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bushehr", + NameFa = "بوشهر", + ProvinceId = 24L, + SortOrder = 1 + }, + new + { + Id = 125L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zanjan", + NameFa = "زنجان", + ProvinceId = 25L, + SortOrder = 1 + }, + new + { + Id = 126L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Semnan", + NameFa = "سمنان", + ProvinceId = 26L, + SortOrder = 1 + }, + new + { + Id = 127L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yazd", + NameFa = "یزد", + ProvinceId = 27L, + SortOrder = 1 + }, + new + { + Id = 128L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zahedan", + NameFa = "زاهدان", + ProvinceId = 28L, + SortOrder = 1 + }, + new + { + Id = 129L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Shahrekord", + NameFa = "شهرکرد", + ProvinceId = 29L, + SortOrder = 1 + }, + new + { + Id = 130L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yasuj", + NameFa = "یاسوج", + ProvinceId = 30L, + SortOrder = 1 + }, + new + { + Id = 131L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ilam", + NameFa = "ایلام", + ProvinceId = 31L, + SortOrder = 1 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.District", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("CityId", "SortOrder"); + + b.ToTable("Districts", "geo"); + + b.HasData( + new + { + Id = 1001L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 1", + NameFa = "منطقه ۱", + SortOrder = 1 + }, + new + { + Id = 1002L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 2", + NameFa = "منطقه ۲", + SortOrder = 2 + }, + new + { + Id = 1003L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 3", + NameFa = "منطقه ۳", + SortOrder = 3 + }, + new + { + Id = 1004L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 4", + NameFa = "منطقه ۴", + SortOrder = 4 + }, + new + { + Id = 1005L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 5", + NameFa = "منطقه ۵", + SortOrder = 5 + }, + new + { + Id = 1006L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 6", + NameFa = "منطقه ۶", + SortOrder = 6 + }, + new + { + Id = 1007L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 7", + NameFa = "منطقه ۷", + SortOrder = 7 + }, + new + { + Id = 1008L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 8", + NameFa = "منطقه ۸", + SortOrder = 8 + }, + new + { + Id = 1009L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 9", + NameFa = "منطقه ۹", + SortOrder = 9 + }, + new + { + Id = 1010L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 10", + NameFa = "منطقه ۱۰", + SortOrder = 10 + }, + new + { + Id = 1011L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 11", + NameFa = "منطقه ۱۱", + SortOrder = 11 + }, + new + { + Id = 1012L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 12", + NameFa = "منطقه ۱۲", + SortOrder = 12 + }, + new + { + Id = 1013L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 13", + NameFa = "منطقه ۱۳", + SortOrder = 13 + }, + new + { + Id = 1014L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 14", + NameFa = "منطقه ۱۴", + SortOrder = 14 + }, + new + { + Id = 1015L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 15", + NameFa = "منطقه ۱۵", + SortOrder = 15 + }, + new + { + Id = 1016L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 16", + NameFa = "منطقه ۱۶", + SortOrder = 16 + }, + new + { + Id = 1017L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 17", + NameFa = "منطقه ۱۷", + SortOrder = 17 + }, + new + { + Id = 1018L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 18", + NameFa = "منطقه ۱۸", + SortOrder = 18 + }, + new + { + Id = 1019L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 19", + NameFa = "منطقه ۱۹", + SortOrder = 19 + }, + new + { + Id = 1020L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 20", + NameFa = "منطقه ۲۰", + SortOrder = 20 + }, + new + { + Id = 1021L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 21", + NameFa = "منطقه ۲۱", + SortOrder = 21 + }, + new + { + Id = 1022L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 22", + NameFa = "منطقه ۲۲", + SortOrder = 22 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.NurseServiceArea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DistrictId") + .HasColumnType("bigint"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("DistrictId"); + + b.HasIndex("NurseId", "CityId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceAreas_Nurse_City_WholeCity") + .HasFilter("[DistrictId] IS NULL AND [DeletedAt] IS NULL"); + + b.HasIndex("NurseId", "CityId", "DistrictId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceAreas_Nurse_City_District") + .HasFilter("[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL"); + + b.ToTable("NurseServiceAreas", "geo"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.Province", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("Provinces", "geo"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tehran", + NameFa = "تهران", + SortOrder = 1 + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Alborz", + NameFa = "البرز", + SortOrder = 2 + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Isfahan", + NameFa = "اصفهان", + SortOrder = 3 + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Fars", + NameFa = "فارس", + SortOrder = 4 + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Razavi Khorasan", + NameFa = "خراسان رضوی", + SortOrder = 5 + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "East Azerbaijan", + NameFa = "آذربایجان شرقی", + SortOrder = 6 + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "West Azerbaijan", + NameFa = "آذربایجان غربی", + SortOrder = 7 + }, + new + { + Id = 8L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Khuzestan", + NameFa = "خوزستان", + SortOrder = 8 + }, + new + { + Id = 9L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qom", + NameFa = "قم", + SortOrder = 9 + }, + new + { + Id = 10L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kerman", + NameFa = "کرمان", + SortOrder = 10 + }, + new + { + Id = 11L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Gilan", + NameFa = "گیلان", + SortOrder = 11 + }, + new + { + Id = 12L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Mazandaran", + NameFa = "مازندران", + SortOrder = 12 + }, + new + { + Id = 13L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Markazi", + NameFa = "مرکزی", + SortOrder = 13 + }, + new + { + Id = 14L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ardabil", + NameFa = "اردبیل", + SortOrder = 14 + }, + new + { + Id = 15L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qazvin", + NameFa = "قزوین", + SortOrder = 15 + }, + new + { + Id = 16L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kermanshah", + NameFa = "کرمانشاه", + SortOrder = 16 + }, + new + { + Id = 17L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "North Khorasan", + NameFa = "خراسان شمالی", + SortOrder = 17 + }, + new + { + Id = 18L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "South Khorasan", + NameFa = "خراسان جنوبی", + SortOrder = 18 + }, + new + { + Id = 19L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hamadan", + NameFa = "همدان", + SortOrder = 19 + }, + new + { + Id = 20L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kurdistan", + NameFa = "کردستان", + SortOrder = 20 + }, + new + { + Id = 21L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Lorestan", + NameFa = "لرستان", + SortOrder = 21 + }, + new + { + Id = 22L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Golestan", + NameFa = "گلستان", + SortOrder = 22 + }, + new + { + Id = 23L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hormozgan", + NameFa = "هرمزگان", + SortOrder = 23 + }, + new + { + Id = 24L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bushehr", + NameFa = "بوشهر", + SortOrder = 24 + }, + new + { + Id = 25L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zanjan", + NameFa = "زنجان", + SortOrder = 25 + }, + new + { + Id = 26L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Semnan", + NameFa = "سمنان", + SortOrder = 26 + }, + new + { + Id = 27L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yazd", + NameFa = "یزد", + SortOrder = 27 + }, + new + { + Id = 28L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sistan and Baluchestan", + NameFa = "سیستان و بلوچستان", + SortOrder = 28 + }, + new + { + Id = 29L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Chaharmahal and Bakhtiari", + NameFa = "چهارمحال و بختیاری", + SortOrder = 29 + }, + new + { + Id = 30L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kohgiluyeh and Boyer-Ahmad", + NameFa = "کهگیلویه و بویراحمد", + SortOrder = 30 + }, + new + { + Id = 31L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ilam", + NameFa = "ایلام", + SortOrder = 31 + }); + }); + modelBuilder.Entity("Baya.Domain.Entities.Holidays.IranianHoliday", b => { b.Property("Id") @@ -390,6 +1417,82 @@ namespace Baya.Infrastructure.Persistence.Migrations }); }); + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AddressLine") + .HasColumnType("nvarchar(max)"); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DistrictId") + .HasColumnType("bigint"); + + b.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("Latitude") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("Longitude") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PostalCode") + .HasColumnType("nvarchar(max)"); + + b.Property("RecipientName") + .HasColumnType("nvarchar(max)"); + + b.Property("RecipientPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("CustomerId") + .IsUnique() + .HasDatabaseName("UX_CustomerAddresses_Customer_Primary") + .HasFilter("[IsPrimary] = 1 AND [DeletedAt] IS NULL"); + + b.HasIndex("DistrictId"); + + b.ToTable("CustomerAddresses", "usr"); + }); + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b => { b.Property("Id") @@ -1140,6 +2243,76 @@ namespace Baya.Infrastructure.Persistence.Migrations .HasForeignKey("ActorUserId"); }); + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.HasOne("Baya.Domain.Entities.Geography.Province", "Province") + .WithMany("Cities") + .HasForeignKey("ProvinceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Province"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.District", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany("Districts") + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("City"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.NurseServiceArea", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany() + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Geography.District", "District") + .WithMany() + .HasForeignKey("DistrictId"); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("City"); + + b.Navigation("District"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerAddress", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany() + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Geography.District", "District") + .WithMany() + .HasForeignKey("DistrictId"); + + b.Navigation("City"); + + b.Navigation("Customer"); + + b.Navigation("District"); + }); + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b => { b.HasOne("Baya.Domain.Entities.User.User", "User") @@ -1293,6 +2466,16 @@ namespace Baya.Infrastructure.Persistence.Migrations b.Navigation("User"); }); + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.Navigation("Districts"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.Province", b => + { + b.Navigation("Cities"); + }); + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b => { b.Navigation("Patients"); diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs index 243e1ba..373e36d 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs @@ -13,6 +13,9 @@ public class UnitOfWork : 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; } public UnitOfWork(ApplicationDbContext db) { @@ -24,6 +27,9 @@ public class UnitOfWork : IUnitOfWork CustomerProfileRepository = new CustomerProfileRepository(_db); PatientRepository = new PatientRepository(_db); NurseBankAccountRepository = new NurseBankAccountRepository(_db); + GeoRepository = new GeoRepository(_db); + NurseServiceAreaRepository = new NurseServiceAreaRepository(_db); + CustomerAddressRepository = new CustomerAddressRepository(_db); } public Task CommitAsync() diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/CustomerAddressRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/CustomerAddressRepository.cs new file mode 100644 index 0000000..80c1c2c --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/CustomerAddressRepository.cs @@ -0,0 +1,78 @@ +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Addresses; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.Identity; +using Baya.Infrastructure.Persistence.Repositories.Common; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Infrastructure.Persistence.Repositories; + +internal sealed class CustomerAddressRepository : BaseAsyncRepository, ICustomerAddressRepository +{ + public CustomerAddressRepository(ApplicationDbContext dbContext) : base(dbContext) + { + } + + public Task AddAsync(CustomerAddress address, CancellationToken cancellationToken) + => base.AddAsync(address); + + public Task GetOwnedAsync(long id, long customerId, CancellationToken cancellationToken) + => Table.FirstOrDefaultAsync(a => a.Id == id && a.CustomerId == customerId, cancellationToken); + + public Task HasAnyAsync(long customerId, CancellationToken cancellationToken) + => TableNoTracking.AnyAsync(a => a.CustomerId == customerId, cancellationToken); + + public async Task ClearOtherPrimaryAsync(long customerId, long addressId, CancellationToken cancellationToken) + => await Entities + .Where(a => a.CustomerId == customerId && a.IsPrimary && a.Id != addressId) + .ExecuteUpdateAsync(setters => setters.SetProperty(a => a.IsPrimary, false), cancellationToken); + + public async Task SetPrimaryAsync(long customerId, long addressId, CancellationToken cancellationToken) + { + // Clear-then-set inside one transaction so the filtered unique index is never momentarily violated. + await using var transaction = await DbContext.Database.BeginTransactionAsync(cancellationToken); + + await Entities + .Where(a => a.CustomerId == customerId && a.IsPrimary) + .ExecuteUpdateAsync(setters => setters.SetProperty(a => a.IsPrimary, false), cancellationToken); + + await Entities + .Where(a => a.Id == addressId && a.CustomerId == customerId) + .ExecuteUpdateAsync(setters => setters.SetProperty(a => a.IsPrimary, true), cancellationToken); + + await transaction.CommitAsync(cancellationToken); + } + + public async Task> ListAsync(long customerId, int page, int pageSize, CancellationToken cancellationToken) + { + var query = TableNoTracking.Where(a => a.CustomerId == customerId); + + var total = await query.CountAsync(cancellationToken); + var items = await query + .OrderByDescending(a => a.IsPrimary) + .ThenByDescending(a => a.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + // The encrypted columns are decrypted by the value converter on materialization — the owner's + // own read is the only path that returns the plaintext address. + .Select(a => new CustomerAddressDto( + a.Id, + a.Title, + a.CityId, + a.City.NameFa, + a.City.NameEn, + a.DistrictId, + a.DistrictId == null ? null : a.District.NameFa, + a.DistrictId == null ? null : a.District.NameEn, + a.AddressLine, + a.PostalCode, + a.Latitude, + a.Longitude, + a.IsPrimary, + a.RecipientName, + a.RecipientPhone)) + .ToListAsync(cancellationToken); + + return new PagedResult(items, total, page, pageSize); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/GeoRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/GeoRepository.cs new file mode 100644 index 0000000..ecddbbc --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/GeoRepository.cs @@ -0,0 +1,121 @@ +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Geography; +using Baya.Domain.Entities.Geography; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Infrastructure.Persistence.Repositories; + +internal sealed class GeoRepository : IGeoRepository +{ + private readonly ApplicationDbContext _db; + + public GeoRepository(ApplicationDbContext db) => _db = db; + + public async Task> ListActiveProvincesAsync(CancellationToken cancellationToken) + => await _db.Set() + .AsNoTracking() + .Where(p => p.IsActive) + .OrderBy(p => p.SortOrder) + .Select(p => new ProvinceDto(p.Id, p.NameFa, p.NameEn, p.SortOrder)) + .ToListAsync(cancellationToken); + + public async Task> ListActiveCitiesAsync(long provinceId, CancellationToken cancellationToken) + => await _db.Set() + .AsNoTracking() + .Where(c => c.ProvinceId == provinceId && c.IsActive && c.Province.IsActive) + .OrderBy(c => c.SortOrder) + .Select(c => new CityDto(c.Id, c.ProvinceId, c.NameFa, c.NameEn, c.SortOrder)) + .ToListAsync(cancellationToken); + + public async Task> ListActiveDistrictsAsync(long cityId, CancellationToken cancellationToken) + => await _db.Set() + .AsNoTracking() + .Where(d => d.CityId == cityId && d.IsActive && d.City.IsActive && d.City.Province.IsActive) + .OrderBy(d => d.SortOrder) + .Select(d => new DistrictDto(d.Id, d.CityId, d.NameFa, d.NameEn, d.SortOrder)) + .ToListAsync(cancellationToken); + + public async Task> GetActiveTreeAsync(CancellationToken cancellationToken) + { + // Three flat ordered reads assembled in memory — avoids a two-level nested collection projection + // (which SQLite can't translate) and keeps the payload cheap for a cached, near-static tree. + var provinces = await _db.Set() + .AsNoTracking() + .Where(p => p.IsActive) + .OrderBy(p => p.SortOrder) + .Select(p => new { p.Id, p.NameFa, p.NameEn, p.SortOrder }) + .ToListAsync(cancellationToken); + + var cities = await _db.Set() + .AsNoTracking() + .Where(c => c.IsActive && c.Province.IsActive) + .OrderBy(c => c.SortOrder) + .Select(c => new { c.Id, c.ProvinceId, c.NameFa, c.NameEn, c.SortOrder }) + .ToListAsync(cancellationToken); + + var districts = await _db.Set() + .AsNoTracking() + .Where(d => d.IsActive && d.City.IsActive && d.City.Province.IsActive) + .OrderBy(d => d.SortOrder) + .Select(d => new DistrictDto(d.Id, d.CityId, d.NameFa, d.NameEn, d.SortOrder)) + .ToListAsync(cancellationToken); + + var districtsByCity = districts + .GroupBy(d => d.CityId) + .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); + + var citiesByProvince = cities + .GroupBy(c => c.ProvinceId) + .ToDictionary( + g => g.Key, + g => (IReadOnlyList)g + .Select(c => new CityTreeDto( + c.Id, + c.NameFa, + c.NameEn, + c.SortOrder, + districtsByCity.TryGetValue(c.Id, out var ds) ? ds : [])) + .ToList()); + + return provinces + .Select(p => new ProvinceTreeDto( + p.Id, + p.NameFa, + p.NameEn, + p.SortOrder, + citiesByProvince.TryGetValue(p.Id, out var cs) ? cs : [])) + .ToList(); + } + + public Task GetProvinceAsync(long id, CancellationToken cancellationToken) + => _db.Set().FirstOrDefaultAsync(p => p.Id == id, cancellationToken); + + public Task GetCityAsync(long id, CancellationToken cancellationToken) + => _db.Set().FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + + public Task GetDistrictAsync(long id, CancellationToken cancellationToken) + => _db.Set().FirstOrDefaultAsync(d => d.Id == id, cancellationToken); + + public async Task AddProvinceAsync(Province province, CancellationToken cancellationToken) + => await _db.Set().AddAsync(province, cancellationToken); + + public async Task AddCityAsync(City city, CancellationToken cancellationToken) + => await _db.Set().AddAsync(city, cancellationToken); + + public async Task AddDistrictAsync(District district, CancellationToken cancellationToken) + => await _db.Set().AddAsync(district, cancellationToken); + + public Task ProvinceExistsAsync(long provinceId, CancellationToken cancellationToken) + => _db.Set().AsNoTracking().AnyAsync(p => p.Id == provinceId, cancellationToken); + + public Task IsCityActiveAsync(long cityId, CancellationToken cancellationToken) + => _db.Set().AsNoTracking() + .AnyAsync(c => c.Id == cityId && c.IsActive && c.Province.IsActive, cancellationToken); + + public Task IsDistrictInActiveCityAsync(long districtId, long cityId, CancellationToken cancellationToken) + => _db.Set().AsNoTracking() + .AnyAsync( + d => d.Id == districtId && d.CityId == cityId && + d.IsActive && d.City.IsActive && d.City.Province.IsActive, + cancellationToken); +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/NurseServiceAreaRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/NurseServiceAreaRepository.cs new file mode 100644 index 0000000..04c89a8 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/NurseServiceAreaRepository.cs @@ -0,0 +1,52 @@ +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Geography; +using Baya.Domain.Entities.Geography; +using Baya.Infrastructure.Persistence.Repositories.Common; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Infrastructure.Persistence.Repositories; + +internal sealed class NurseServiceAreaRepository : BaseAsyncRepository, INurseServiceAreaRepository +{ + public NurseServiceAreaRepository(ApplicationDbContext dbContext) : base(dbContext) + { + } + + public Task AddAsync(NurseServiceArea area, CancellationToken cancellationToken) + => base.AddAsync(area); + + public Task GetOwnedAsync(long id, long nurseId, CancellationToken cancellationToken) + => Table.FirstOrDefaultAsync(a => a.Id == id && a.NurseId == nurseId, cancellationToken); + + public Task DuplicateExistsAsync(long nurseId, long cityId, long? districtId, CancellationToken cancellationToken) + => TableNoTracking.AnyAsync( + a => a.NurseId == nurseId && a.CityId == cityId && a.DistrictId == districtId, + cancellationToken); + + public async Task> ListAsync(long nurseId, int page, int pageSize, CancellationToken cancellationToken) + { + var query = TableNoTracking.Where(a => a.NurseId == nurseId); + + var total = await query.CountAsync(cancellationToken); + var items = await query + // Whole-city rows (district_id NULL) first, then by id. + .OrderByDescending(a => a.DistrictId == null) + .ThenBy(a => a.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(a => new NurseServiceAreaDto( + a.Id, + a.CityId, + a.City.NameFa, + a.City.NameEn, + a.DistrictId, + a.DistrictId == null ? null : a.District.NameFa, + a.DistrictId == null ? null : a.District.NameEn, + a.DistrictId == null, + a.IsActive)) + .ToListAsync(cancellationToken); + + return new PagedResult(items, total, page, pageSize); + } +} diff --git a/server/src/Tests/Baya.Test.Api/AdminGeoApiTests.cs b/server/src/Tests/Baya.Test.Api/AdminGeoApiTests.cs new file mode 100644 index 0000000..45355c9 --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/AdminGeoApiTests.cs @@ -0,0 +1,61 @@ +using System.Net; +using System.Net.Http.Json; + +namespace Baya.Test.Api; + +public class AdminGeoApiTests(BayaApiFactory factory) : IClassFixture +{ + private const long TehranProvinceId = 1; + + [Fact] + public async Task CreateCity_ThenDeactivate_HidesWithoutDeleting() + { + var client = factory.CreateClient(); + await AdminTestClient.AuthenticateAsync(factory, client, "09129000001"); + + var create = await client.PostAsJsonAsync("/api/v1/admin_geo/create_city", + new { provinceId = TehranProvinceId, nameFa = "شهر آزمایشی", nameEn = "Test City", sortOrder = 99 }); + Assert.Equal(HttpStatusCode.OK, create.StatusCode); + var cityId = (await AuthTestClient.ReadDataAsync(create)).GetProperty("id").GetInt64(); + + // Public list includes the new active city. + Assert.True(await CityVisible(client, cityId)); + + // Deactivate → it disappears from the public dropdown, but is not deleted. + var deactivate = await client.PostAsJsonAsync($"/api/v1/admin_geo/set_city_active/{cityId}", new { isActive = false }); + Assert.Equal(HttpStatusCode.OK, deactivate.StatusCode); + Assert.False(await CityVisible(client, cityId)); + + // Reactivate → it returns. + var reactivate = await client.PostAsJsonAsync($"/api/v1/admin_geo/set_city_active/{cityId}", new { isActive = true }); + Assert.Equal(HttpStatusCode.OK, reactivate.StatusCode); + Assert.True(await CityVisible(client, cityId)); + } + + [Fact] + public async Task CreateProvince_Unauthenticated_Returns401() + { + var client = factory.CreateClient(); + var response = await client.PostAsJsonAsync("/api/v1/admin_geo/create_province", + new { nameFa = "x", nameEn = "x", sortOrder = 1 }); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task CreateProvince_MissingName_Returns400() + { + var client = factory.CreateClient(); + await AdminTestClient.AuthenticateAsync(factory, client, "09129000002"); + + var response = await client.PostAsJsonAsync("/api/v1/admin_geo/create_province", + new { nameFa = "", nameEn = "", sortOrder = 1 }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + private static async Task CityVisible(HttpClient client, long cityId) + { + var response = await client.GetAsync($"/api/v1/geo/cities?province_id={TehranProvinceId}"); + var cities = (await AuthTestClient.ReadDataAsync(response)).EnumerateArray(); + return cities.Any(c => c.GetProperty("id").GetInt64() == cityId); + } +} diff --git a/server/src/Tests/Baya.Test.Api/AdminTestClient.cs b/server/src/Tests/Baya.Test.Api/AdminTestClient.cs new file mode 100644 index 0000000..f03beed --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/AdminTestClient.cs @@ -0,0 +1,36 @@ +using Baya.Application.Contracts.Identity; +using Baya.Domain.Entities.User; +using Baya.Infrastructure.Identity.Identity.Manager; +using Microsoft.Extensions.DependencyInjection; + +namespace Baya.Test.Api; + +/// +/// Logs a user in as an admin: creates the user and grants the seeded admin role before +/// login so the minted access token already carries the admin role claim (role claims are baked at mint +/// time). The dynamic-permission policy grants full access to the admin role. +/// +internal static class AdminTestClient +{ + public static async Task AuthenticateAsync(BayaApiFactory factory, HttpClient client, string phone) + { + using (var scope = factory.Services.CreateScope()) + { + var userManager = scope.ServiceProvider.GetRequiredService(); + var roleManager = scope.ServiceProvider.GetRequiredService(); + + var user = await userManager.GetUserByPhoneNumber(phone); + if (user is null) + { + await userManager.CreateUser(new User { UserName = $"admin_{Guid.NewGuid():N}", PhoneNumber = phone }); + user = await userManager.GetUserByPhoneNumber(phone); + } + + var adminRole = await roleManager.FindByNameAsync(RoleNames.Admin); + await userManager.AddUserToRoleAsync(user!, adminRole!); + } + + var tokens = await AuthTestClient.LoginAsync(factory, client, phone); + AuthTestClient.UseBearer(client, tokens.GetProperty("accessToken").GetString()!); + } +} diff --git a/server/src/Tests/Baya.Test.Api/CustomerAddressesApiTests.cs b/server/src/Tests/Baya.Test.Api/CustomerAddressesApiTests.cs new file mode 100644 index 0000000..90ce061 --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/CustomerAddressesApiTests.cs @@ -0,0 +1,99 @@ +using System.Net; +using System.Net.Http.Json; + +namespace Baya.Test.Api; + +public class CustomerAddressesApiTests(BayaApiFactory factory) : IClassFixture +{ + private const long TehranCityId = 101; + + // ASCII address line so the DbContext's Persian-digit normalization (Fa2En) can't alter the value + // between write and read, keeping the decrypt round-trip assertion exact. + private static object AddressBody(string title, bool isPrimary = false, string addressLine = "Valiasr Street No 10") => new + { + title, + cityId = TehranCityId, + addressLine, + postalCode = "1234567890", + recipientName = "علی", + recipientPhone = "09120000000", + isPrimary + }; + + [Fact] + public async Task Create_Geocoded_PrimaryFirst_DecryptedForOwner() + { + var client = factory.CreateClient(); + await ProfileTestClient.AuthenticateAsync(factory, client, "09126000001", "customer"); + + var create = await client.PostAsJsonAsync("/api/v1/customer_addresses/create", AddressBody("خانه", isPrimary: true)); + Assert.Equal(HttpStatusCode.OK, create.StatusCode); + var created = await AuthTestClient.ReadDataAsync(create); + Assert.True(created.GetProperty("isPrimary").GetBoolean()); + // Geocoded by the IGeocoder mock. + Assert.False(created.GetProperty("latitude").ValueKind == System.Text.Json.JsonValueKind.Null); + // The owner's own read returns the decrypted address. + Assert.Equal("Valiasr Street No 10", created.GetProperty("addressLine").GetString()); + + var list = await client.GetAsync("/api/v1/customer_addresses/list"); + var listData = await AuthTestClient.ReadDataAsync(list); + Assert.Equal(1, listData.GetProperty("total").GetInt32()); + Assert.Equal("Valiasr Street No 10", + listData.GetProperty("items")[0].GetProperty("addressLine").GetString()); + } + + [Fact] + public async Task SecondPrimary_ClearsFirst_SinglePrimaryInvariant() + { + var client = factory.CreateClient(); + await ProfileTestClient.AuthenticateAsync(factory, client, "09126000002", "customer"); + + var first = await client.PostAsJsonAsync("/api/v1/customer_addresses/create", AddressBody("خانه", isPrimary: true)); + var firstId = (await AuthTestClient.ReadDataAsync(first)).GetProperty("id").GetInt64(); + + var second = await client.PostAsJsonAsync("/api/v1/customer_addresses/create", AddressBody("کار", isPrimary: true)); + var secondId = (await AuthTestClient.ReadDataAsync(second)).GetProperty("id").GetInt64(); + + var list = await client.GetAsync("/api/v1/customer_addresses/list"); + var items = (await AuthTestClient.ReadDataAsync(list)).GetProperty("items").EnumerateArray().ToList(); + + Assert.Single(items, i => i.GetProperty("isPrimary").GetBoolean()); + Assert.True(Primary(items, secondId)); + Assert.False(Primary(items, firstId)); + } + + [Fact] + public async Task NoGeoMarker_SavesWithoutCoordinates() + { + var client = factory.CreateClient(); + await ProfileTestClient.AuthenticateAsync(factory, client, "09126000003", "customer"); + + var create = await client.PostAsJsonAsync("/api/v1/customer_addresses/create", + AddressBody("خانه", isPrimary: true, addressLine: "NO_GEO unresolved address")); + var created = await AuthTestClient.ReadDataAsync(create); + + Assert.Equal(System.Text.Json.JsonValueKind.Null, created.GetProperty("latitude").ValueKind); + } + + [Fact] + public async Task List_Unauthenticated_Returns401() + { + var client = factory.CreateClient(); + var response = await client.GetAsync("/api/v1/customer_addresses/list"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Create_MissingAddressLine_Returns400() + { + var client = factory.CreateClient(); + await ProfileTestClient.AuthenticateAsync(factory, client, "09126000004", "customer"); + + var response = await client.PostAsJsonAsync("/api/v1/customer_addresses/create", + AddressBody("خانه", addressLine: "")); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + private static bool Primary(IEnumerable items, long id) => + items.Single(i => i.GetProperty("id").GetInt64() == id).GetProperty("isPrimary").GetBoolean(); +} diff --git a/server/src/Tests/Baya.Test.Api/GeoApiTests.cs b/server/src/Tests/Baya.Test.Api/GeoApiTests.cs new file mode 100644 index 0000000..9c8a4eb --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/GeoApiTests.cs @@ -0,0 +1,71 @@ +using System.Net; + +namespace Baya.Test.Api; + +public class GeoApiTests(BayaApiFactory factory) : IClassFixture +{ + // Seeded ids: Tehran province = 1, Tehran city = 101, Mashhad city = 105 (no districts). + private const long TehranProvinceId = 1; + private const long TehranCityId = 101; + private const long MashhadCityId = 105; + + [Fact] + public async Task Provinces_ReturnsSeeded31_TehranFirst() + { + var client = factory.CreateClient(); + + var response = await client.GetAsync("/api/v1/geo/provinces"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var provinces = (await AuthTestClient.ReadDataAsync(response)).EnumerateArray().ToList(); + Assert.Equal(31, provinces.Count); + Assert.Equal("Tehran", provinces[0].GetProperty("nameEn").GetString()); + } + + [Fact] + public async Task Cities_ForTehranProvince_IncludesTehran() + { + var client = factory.CreateClient(); + + var response = await client.GetAsync($"/api/v1/geo/cities?province_id={TehranProvinceId}"); + var cities = (await AuthTestClient.ReadDataAsync(response)).EnumerateArray().ToList(); + + Assert.Contains(cities, c => c.GetProperty("id").GetInt64() == TehranCityId); + } + + [Fact] + public async Task Districts_ForTehran_Returns22() + { + var client = factory.CreateClient(); + + var response = await client.GetAsync($"/api/v1/geo/districts?city_id={TehranCityId}"); + var districts = (await AuthTestClient.ReadDataAsync(response)).EnumerateArray().ToList(); + + Assert.Equal(22, districts.Count); + } + + [Fact] + public async Task Districts_ForMashhad_IsEmpty_WholeCityOnly() + { + var client = factory.CreateClient(); + + var response = await client.GetAsync($"/api/v1/geo/districts?city_id={MashhadCityId}"); + var districts = (await AuthTestClient.ReadDataAsync(response)).EnumerateArray().ToList(); + + Assert.Empty(districts); + } + + [Fact] + public async Task Tree_ReturnsActiveHierarchy() + { + var client = factory.CreateClient(); + + var response = await client.GetAsync("/api/v1/geo/tree"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var provinces = (await AuthTestClient.ReadDataAsync(response)).EnumerateArray().ToList(); + Assert.Equal(31, provinces.Count); + var tehran = provinces.Single(p => p.GetProperty("id").GetInt64() == TehranProvinceId); + Assert.NotEmpty(tehran.GetProperty("cities").EnumerateArray()); + } +} diff --git a/server/src/Tests/Baya.Test.Api/NurseServiceAreasApiTests.cs b/server/src/Tests/Baya.Test.Api/NurseServiceAreasApiTests.cs new file mode 100644 index 0000000..1fc832b --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/NurseServiceAreasApiTests.cs @@ -0,0 +1,66 @@ +using System.Net; +using System.Net.Http.Json; + +namespace Baya.Test.Api; + +public class NurseServiceAreasApiTests(BayaApiFactory factory) : IClassFixture +{ + private const long TehranCityId = 101; + private const long TehranDistrict1 = 1001; + + private static async Task SetUpNurseAsync(BayaApiFactory factory, HttpClient client, string phone) + { + await ProfileTestClient.AuthenticateAsync(factory, client, phone, "nurse"); + await client.PostAsJsonAsync("/api/v1/nurse_profiles/upsert", + new { bio = "b", yearsOfExperience = 5, educationLevel = "", educationField = "", specializationsJson = "[]" }); + } + + [Fact] + public async Task Add_WholeCity_And_District_WithDuplicatesRejected() + { + var client = factory.CreateClient(); + await SetUpNurseAsync(factory, client, "09125000001"); + + // Whole city (no district). + var wholeCity = await client.PostAsJsonAsync("/api/v1/nurse_service_areas/add", new { cityId = TehranCityId }); + Assert.Equal(HttpStatusCode.OK, wholeCity.StatusCode); + Assert.True((await AuthTestClient.ReadDataAsync(wholeCity)).GetProperty("isWholeCity").GetBoolean()); + + // Duplicate whole city → 409 (not a 500). + var dupWholeCity = await client.PostAsJsonAsync("/api/v1/nurse_service_areas/add", new { cityId = TehranCityId }); + Assert.Equal(HttpStatusCode.Conflict, dupWholeCity.StatusCode); + + // City + district is a distinct coverage row. + var district = await client.PostAsJsonAsync("/api/v1/nurse_service_areas/add", + new { cityId = TehranCityId, districtId = TehranDistrict1 }); + Assert.Equal(HttpStatusCode.OK, district.StatusCode); + Assert.False((await AuthTestClient.ReadDataAsync(district)).GetProperty("isWholeCity").GetBoolean()); + + // Duplicate district → 409. + var dupDistrict = await client.PostAsJsonAsync("/api/v1/nurse_service_areas/add", + new { cityId = TehranCityId, districtId = TehranDistrict1 }); + Assert.Equal(HttpStatusCode.Conflict, dupDistrict.StatusCode); + + var list = await client.GetAsync("/api/v1/nurse_service_areas/list"); + var data = await AuthTestClient.ReadDataAsync(list); + Assert.Equal(2, data.GetProperty("total").GetInt32()); + } + + [Fact] + public async Task List_Unauthenticated_Returns401() + { + var client = factory.CreateClient(); + var response = await client.GetAsync("/api/v1/nurse_service_areas/list"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Add_InvalidCity_Returns400() + { + var client = factory.CreateClient(); + await SetUpNurseAsync(factory, client, "09125000002"); + + var response = await client.PostAsJsonAsync("/api/v1/nurse_service_areas/add", new { cityId = 0 }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Geography/CustomerAddressHandlersTests.cs b/server/src/Tests/Baya.Test.Foundation/Geography/CustomerAddressHandlersTests.cs new file mode 100644 index 0000000..4ac9100 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Geography/CustomerAddressHandlersTests.cs @@ -0,0 +1,111 @@ +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Features.Addresses.Commands.CreateAddress; +using Baya.Application.Features.Addresses.Commands.SetPrimaryAddress; +using Baya.Domain.Entities.Geography; +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.User; +using NSubstitute; +using NSubstitute.ReturnsExtensions; + +namespace Baya.Test.Foundation.Geography; + +public class CustomerAddressHandlersTests +{ + private readonly ICurrentUser _currentUser = Substitute.For(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly ICustomerProfileRepository _customers = Substitute.For(); + private readonly ICustomerAddressRepository _addresses = Substitute.For(); + private readonly IGeoRepository _geo = Substitute.For(); + private readonly IGeocoder _geocoder = Substitute.For(); + + public CustomerAddressHandlersTests() + { + _currentUser.UserId.Returns(7); + _currentUser.Roles.Returns([RoleNames.Customer]); + _unitOfWork.CustomerProfileRepository.Returns(_customers); + _unitOfWork.CustomerAddressRepository.Returns(_addresses); + _unitOfWork.GeoRepository.Returns(_geo); + _geo.GetCityAsync(Arg.Any(), Arg.Any()) + .Returns(new City { NameFa = "تهران", NameEn = "Tehran" }); + _geo.IsCityActiveAsync(Arg.Any(), Arg.Any()).Returns(true); + } + + private CreateAddressCommand Body(bool isPrimary = false) => + new("خانه", 101L, null, "خیابان ولیعصر، پلاک ۱۰", "1234567890", "علی", "09120000000", isPrimary); + + [Fact] + public async Task Create_FirstAddress_IsPrimaryAndGeocoded() + { + _customers.GetProfileIdByUserIdAsync(7, Arg.Any()).Returns(42L); + _addresses.HasAnyAsync(42L, Arg.Any()).Returns(false); + _geocoder.GeocodeAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new GeocodeResult(35.6892m, 51.3890m, "Tehran", 0.9)); + var handler = new CreateAddressCommandHandler(_currentUser, _unitOfWork, _geocoder); + + var result = await handler.Handle(Body(), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(35.6892m, result.Result.Latitude); + await _addresses.Received(1).AddAsync( + Arg.Is(a => a.IsPrimary && a.Latitude == 35.6892m && a.CustomerId == 42L), + Arg.Any()); + } + + [Fact] + public async Task Create_SecondAddressAsPrimary_ClearsPriorPrimary() + { + _customers.GetProfileIdByUserIdAsync(7, Arg.Any()).Returns(42L); + _addresses.HasAnyAsync(42L, Arg.Any()).Returns(true); + _geocoder.GeocodeAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new GeocodeResult(1m, 1m, "x", 0.9)); + var handler = new CreateAddressCommandHandler(_currentUser, _unitOfWork, _geocoder); + + var result = await handler.Handle(Body(isPrimary: true), CancellationToken.None); + + Assert.True(result.IsSuccess); + await _addresses.Received(1).ClearOtherPrimaryAsync(42L, 0L, Arg.Any()); + } + + [Fact] + public async Task Create_LowConfidenceGeocode_SavedWithoutCoordinates() + { + _customers.GetProfileIdByUserIdAsync(7, Arg.Any()).Returns(42L); + _addresses.HasAnyAsync(42L, Arg.Any()).Returns(false); + _geocoder.GeocodeAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new GeocodeResult(null, null, "x", 0.2)); + var handler = new CreateAddressCommandHandler(_currentUser, _unitOfWork, _geocoder); + + var result = await handler.Handle(Body(), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Null(result.Result.Latitude); + await _addresses.Received(1).AddAsync( + Arg.Is(a => a.Latitude == null && a.Longitude == null), + Arg.Any()); + } + + [Fact] + public async Task Create_NonCustomer_IsForbidden() + { + _currentUser.Roles.Returns([RoleNames.Nurse]); + var handler = new CreateAddressCommandHandler(_currentUser, _unitOfWork, _geocoder); + + var result = await handler.Handle(Body(), CancellationToken.None); + + Assert.True(result.IsForbidden); + } + + [Fact] + public async Task SetPrimary_OtherCustomersAddress_IsNotFound() + { + _customers.GetProfileIdByUserIdAsync(7, Arg.Any()).Returns(42L); + _addresses.GetOwnedAsync(99L, 42L, Arg.Any()).ReturnsNull(); + var handler = new SetPrimaryAddressCommandHandler(_currentUser, _unitOfWork); + + var result = await handler.Handle(new SetPrimaryAddressCommand(99L), CancellationToken.None); + + Assert.True(result.IsNotFound); + await _addresses.DidNotReceive().SetPrimaryAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Geography/NurseServiceAreaHandlersTests.cs b/server/src/Tests/Baya.Test.Foundation/Geography/NurseServiceAreaHandlersTests.cs new file mode 100644 index 0000000..7d2265f --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Geography/NurseServiceAreaHandlersTests.cs @@ -0,0 +1,110 @@ +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Features.ServiceAreas.Commands.AddNurseServiceArea; +using Baya.Application.Features.ServiceAreas.Commands.RemoveNurseServiceArea; +using Baya.Domain.Entities.Geography; +using Baya.Domain.Entities.User; +using NSubstitute; +using NSubstitute.ReturnsExtensions; + +namespace Baya.Test.Foundation.Geography; + +public class NurseServiceAreaHandlersTests +{ + private readonly ICurrentUser _currentUser = Substitute.For(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly INurseProfileRepository _nurses = Substitute.For(); + private readonly IGeoRepository _geo = Substitute.For(); + private readonly INurseServiceAreaRepository _areas = Substitute.For(); + + public NurseServiceAreaHandlersTests() + { + _currentUser.UserId.Returns(7); + _currentUser.Roles.Returns([RoleNames.Nurse]); + _unitOfWork.NurseProfileRepository.Returns(_nurses); + _unitOfWork.GeoRepository.Returns(_geo); + _unitOfWork.NurseServiceAreaRepository.Returns(_areas); + _nurses.GetProfileIdByUserIdAsync(7, Arg.Any()).Returns(42L); + _geo.GetCityAsync(Arg.Any(), Arg.Any()) + .Returns(new City { NameFa = "تهران", NameEn = "Tehran" }); + _geo.IsCityActiveAsync(Arg.Any(), Arg.Any()).Returns(true); + } + + [Fact] + public async Task Add_WholeCity_PersistsWholeCityRow() + { + _areas.DuplicateExistsAsync(42L, 101L, null, Arg.Any()).Returns(false); + var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork); + + var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, null), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.True(result.Result.IsWholeCity); + await _areas.Received(1).AddAsync( + Arg.Is(a => a.NurseId == 42L && a.CityId == 101L && a.DistrictId == null), + Arg.Any()); + await _unitOfWork.Received(1).CommitAsync(); + } + + [Fact] + public async Task Add_DuplicateWholeCity_ReturnsConflictNotPersisted() + { + _areas.DuplicateExistsAsync(42L, 101L, null, Arg.Any()).Returns(true); + var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork); + + var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, null), CancellationToken.None); + + Assert.True(result.IsConflict); + await _areas.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + await _unitOfWork.DidNotReceive().CommitAsync(); + } + + [Fact] + public async Task Add_DuplicateDistrict_ReturnsConflict() + { + _geo.IsDistrictInActiveCityAsync(5L, 101L, Arg.Any()).Returns(true); + _geo.GetDistrictAsync(5L, Arg.Any()) + .Returns(new District { NameFa = "منطقه ۱", NameEn = "District 1" }); + _areas.DuplicateExistsAsync(42L, 101L, 5L, Arg.Any()).Returns(true); + var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork); + + var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, 5L), CancellationToken.None); + + Assert.True(result.IsConflict); + } + + [Fact] + public async Task Add_DistrictNotInCity_Fails() + { + _geo.IsDistrictInActiveCityAsync(999L, 101L, Arg.Any()).Returns(false); + var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork); + + var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, 999L), CancellationToken.None); + + Assert.False(result.IsSuccess); + await _areas.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Add_NonNurse_IsForbidden() + { + _currentUser.Roles.Returns([RoleNames.Customer]); + var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork); + + var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, null), CancellationToken.None); + + Assert.True(result.IsForbidden); + } + + [Fact] + public async Task Remove_OtherNursesArea_IsNotFound() + { + _areas.GetOwnedAsync(99L, 42L, Arg.Any()).ReturnsNull(); + var handler = new RemoveNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, Substitute.For()); + + var result = await handler.Handle(new RemoveNurseServiceAreaCommand(99L), CancellationToken.None); + + Assert.True(result.IsNotFound); + await _unitOfWork.DidNotReceive().CommitAsync(); + } +}
FieldTypeNotes