diff --git a/dev/contracts/domains/catalog.md b/dev/contracts/domains/catalog.md new file mode 100644 index 0000000..9d8547c --- /dev/null +++ b/dev/contracts/domains/catalog.md @@ -0,0 +1,142 @@ +# Contract — Service catalog & nurse pricing variants (backend phase b5) + +> The admin catalog skeleton (categories → option groups → option values) and the nurse pricing layer +> (variants — the atomic bookable unit). 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 b5). + +**Status:** live as of backend-phase-b5 · **Frontend consumer:** frontend-phase-f4-b5 + +> **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-category is +> `POST api/v1/admin_catalog/create_category`, not `POST api/v1/admin/catalog/categories`. Mutations use +> **POST**; ids for edit/toggle come from the **route**, never the body. All responses use the standard +> `{ succeeded, statusCode, data }` envelope; `data` shapes are below. JSON bodies/fields are **camelCase**. + +## Enums used +- `price_unit`: `per_hour` | `per_session` | `per_half_day` | `per_day` | `per_24h` — the unit a variant's + `price` is quoted in. `per_24h` (شبانه‌روزی / live-in) and `per_day` are first-class. Stable string codes; + the client maps them to i18n labels, never derives a label from the code. + +## Key semantics (read first) +- **The bookable unit is the VARIANT, not the nurse.** A nurse with no active variant is not bookable. + Search (b7) and booking (b8) operate on a variant. +- **`price` is IRR Rials, integer, on the wire as a string of digits** (e.g. `"8000000"`). No floats, no + Toman. The engagement **total is `price` + `price_unit` + `session_count`** — never derive a total from + `price` alone. +- **A NULL-category option group is cross-category** — it applies to *every* category. The applicable + groups for a category = its own groups **plus** every cross-category group. +- **All required dimensions must be answered** on variant create (including required cross-category ones); + **one value per dimension**; a value must belong to its group and be active. +- **Duplicate identical listings are rejected** — same nurse + same category + identical answered + option-set → **409**. +- **`display_name` auto-generates** from the category + chosen value labels but is nurse-editable. +- **Deactivate, never delete.** Categories/groups/values/variants soft-deactivate; a deactivated variant is + unbookable and drops out of the public view. +- **Every catalog row carries `nameFa` (primary) + `nameEn`.** The client picks by locale. + +## Public catalog browse — `CatalogController` (no auth) + +### `GET api/v1/catalog/categories?page=&page_size=` +- Active categories ordered by `sortOrder`, **paginated** (default `page_size` 50, max 100). Cached. `data`: + `PagedResult`. + +### `GET api/v1/catalog/option_groups?category_id={id}` +- A category's **applicable** option groups — its own active groups **plus** every cross-category (NULL) + active group — each with its active values, ordered by `sortOrder`. Cached. **Empty list is valid** + (no dimensions defined yet). `data`: `OptionGroupDto[]`. + +## Admin catalog curation — `AdminCatalogController` (admin / dynamic-permission) +Every write **invalidates the catalog cache**. Both labels required (`nameFa`/`nameEn`). No hard delete. + +| Route | Body | Result | +| --- | --- | --- | +| `POST admin_catalog/create_category` | `{ nameFa, nameEn, descriptionFa?, descriptionEn?, iconKey?, sortOrder }` | `ServiceCategoryDto` | +| `POST admin_catalog/update_category/{id}` | `{ nameFa, nameEn, descriptionFa?, descriptionEn?, iconKey?, sortOrder }` | `ServiceCategoryDto` | +| `POST admin_catalog/set_category_active/{id}` | `{ isActive }` | `true` | +| `POST admin_catalog/create_option_group` | `{ serviceCategoryId?, nameFa, nameEn, isRequired, sortOrder }` | `OptionGroupDto` | +| `POST admin_catalog/update_option_group/{id}` | `{ serviceCategoryId?, nameFa, nameEn, isRequired, sortOrder }` | `OptionGroupDto` | +| `POST admin_catalog/create_option_value` | `{ optionGroupId, nameFa, nameEn, sortOrder }` | `OptionValueDto` | +| `POST admin_catalog/update_option_value/{id}` | `{ nameFa, nameEn, sortOrder, isActive }` | `OptionValueDto` | + +- **`serviceCategoryId = null` on a group = cross-category** (applies to every category). +- `update_option_value` intentionally does **not** re-parent a value to another group (it would change the + meaning of variants that already answered with it). +- **Failure cases:** `400` empty labels / unknown parent (`serviceCategoryId`/`optionGroupId`); `401` + unauthenticated; `403` non-admin; `404` unknown id on update/toggle. + +## Nurse variants — `NurseVariantsController` (authenticated; nurse-owner-scoped in handler) + +### `POST api/v1/nurse_variants/create` +- **Body:** `{ serviceCategoryId, options: [{ optionGroupId, optionValueId }], price, priceUnit, sessionCount?, displayName? }` + — `price` is a string of digits; `options` answers the dimensions (one value per group). Omit + `displayName` to auto-generate it. +- **`data`:** `VariantDto` (`isActive: true`, `displayName` auto-generated from labels unless overridden). +- **Failure cases:** `400` invalid price (non-digits/≤0)/`priceUnit`/`sessionCount`; a **missing required + dimension** (names it); a value not belonging to its group; the same group answered twice; an unknown/ + inapplicable group or value; missing/inactive category. `401` unauthenticated; `403` caller is not a + nurse (or has no nurse profile). **`409`** a duplicate identical listing (same category + option-set) — + never a `500`. +- **Tenancy/side effects:** the nurse is derived from the caller, never the body. (Deferred: this is the + trigger point for the b7 `nurse_search_index` fan-out.) + +### `POST api/v1/nurse_variants/update/{id}` +- **Body:** `{ price, priceUnit, sessionCount?, displayName? }` — edits price/unit/session/display only. The + **option-set is immutable** here (change dimensions = create-new + deactivate-old). A blank `displayName` + leaves the current one unchanged. `data`: `VariantDto`. `404` if not owned/absent (existence not leaked). + +### `POST api/v1/nurse_variants/set_active/{id}` +- **Body:** `{ isActive }`. Deactivate/reactivate — **never hard-delete**. `data`: `true`. `404` if not owned. + +### `GET api/v1/nurse_variants/list?page=&page_size=` +- The nurse's own offerings — **active and inactive**, active-first, paginated. `data`: + `PagedResult`. + +### `GET api/v1/nurse_variants/get/{id}` +- **Auth:** none required (owner/admin get the full view; any other caller gets the **public** projection). +- The owning nurse and an admin see the variant in any state; anyone else sees it only when **active**. + `data`: `VariantDto`. `404` when absent, or inactive to a non-owner. + +## Shared shapes +- `ServiceCategoryDto`: `id` (long), `nameFa`, `nameEn`, `descriptionFa` (string?), `descriptionEn` + (string?), `iconKey` (string?), `sortOrder` (int), `isActive` (bool). +- `OptionValueDto`: `id`, `nameFa`, `nameEn`, `sortOrder`, `isActive`. +- `OptionGroupDto`: `id`, `serviceCategoryId` (long?, **null = cross-category**), `nameFa`, `nameEn`, + `isRequired` (bool), `sortOrder`, `isActive`, `values` (`OptionValueDto[]`). +- `VariantOptionDto`: `optionGroupId`, `groupNameFa`, `groupNameEn`, `optionValueId`, `valueNameFa`, + `valueNameEn`. +- `VariantDto`: `id`, `serviceCategoryId`, `categoryNameFa`, `categoryNameEn`, `price` (**string of IRR + digits**), `priceUnit` (enum), `sessionCount` (int?), `displayName`, `isActive` (bool), `options` + (`VariantOptionDto[]`). +- `PagedResult`: `items` (`T[]`), `total` (int), `page` (int), `pageSize` (int). + +## Seed (available on a fresh DB) +Five categories, ordered by `sortOrder`, `nameFa` + `nameEn`: Elderly Care (id 1, مراقبت از سالمند), +Post-Surgery Recovery (2, مراقبت پس از جراحی), Infant Care (3, مراقبت از نوزاد), Chronic Illness +Management (4, مدیریت بیماری مزمن), Companionship (5, همراهی و مراقبت روزمره). **Option groups/values are +not seeded** — an admin authors them per category (EAV; no migration needed). + +## Example — build a variant +``` +# 1) admin defines a dimension for Elderly Care +POST /api/v1/admin_catalog/create_option_group + { "serviceCategoryId": 1, "nameFa": "نوع شیفت", "nameEn": "Shift type", "isRequired": true, "sortOrder": 1 } + -> data.id = 11 +POST /api/v1/admin_catalog/create_option_value + { "optionGroupId": 11, "nameFa": "شبانه‌روزی", "nameEn": "Live-in", "sortOrder": 1 } -> data.id = 101 + +# 2) nurse builds a priced variant +POST /api/v1/nurse_variants/create + { "serviceCategoryId": 1, "options": [{ "optionGroupId": 11, "optionValueId": 101 }], + "price": "8000000", "priceUnit": "per_24h" } + -> 200 { id, isActive: true, price: "8000000", displayName: "مراقبت از سالمند · شبانه‌روزی", options: [...] } + +# 3) repeating the exact same create -> 409 (duplicate identical listing) +# 4) omitting the required shift-type value -> 400 (missing required dimension) +``` + +## Changelog +- b5 — initial contract: public catalog browse (categories + applicable option groups), admin catalog CRUD + + set-active, nurse variant create/update/set-active/list/get; `price_unit` enum; IRR-string money; + `409` duplicate listing / `400` missing required dimension. diff --git a/dev/contracts/openapi/swagger.v1.json b/dev/contracts/openapi/swagger.v1.json index b6c1ed3..7106d57 100644 --- a/dev/contracts/openapi/swagger.v1.json +++ b/dev/contracts/openapi/swagger.v1.json @@ -7,10 +7,597 @@ }, "servers": [ { - "url": "http://localhost" + "url": "http://localhost:5099" } ], "paths": { + "/api/v1/admin_catalog/create_category": { + "post": { + "tags": [ + "AdminCatalog" + ], + "operationId": "AdminCatalog_CreateCategory", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateServiceCategoryCommand" + } + } + }, + "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/ApiResultOfServiceCategoryDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_catalog/update_category/{id}": { + "post": { + "tags": [ + "AdminCatalog" + ], + "operationId": "AdminCatalog_UpdateCategory", + "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/UpdateServiceCategoryCommand" + } + } + }, + "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/ApiResultOfServiceCategoryDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_catalog/set_category_active/{id}": { + "post": { + "tags": [ + "AdminCatalog" + ], + "operationId": "AdminCatalog_SetCategoryActive", + "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/SetServiceCategoryActiveCommand" + } + } + }, + "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_catalog/create_option_group": { + "post": { + "tags": [ + "AdminCatalog" + ], + "operationId": "AdminCatalog_CreateOptionGroup", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateServiceOptionGroupCommand" + } + } + }, + "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/ApiResultOfOptionGroupDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_catalog/update_option_group/{id}": { + "post": { + "tags": [ + "AdminCatalog" + ], + "operationId": "AdminCatalog_UpdateOptionGroup", + "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/UpdateServiceOptionGroupCommand" + } + } + }, + "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/ApiResultOfOptionGroupDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_catalog/create_option_value": { + "post": { + "tags": [ + "AdminCatalog" + ], + "operationId": "AdminCatalog_CreateOptionValue", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateServiceOptionValueCommand" + } + } + }, + "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/ApiResultOfOptionValueDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin_catalog/update_option_value/{id}": { + "post": { + "tags": [ + "AdminCatalog" + ], + "operationId": "AdminCatalog_UpdateOptionValue", + "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/UpdateServiceOptionValueCommand" + } + } + }, + "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/ApiResultOfOptionValueDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/admin_geo/create_province": { "post": { "tags": [ @@ -1177,6 +1764,158 @@ ] } }, + "/api/v1/catalog/categories": { + "get": { + "tags": [ + "Catalog" + ], + "operationId": "Catalog_Categories", + "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/ApiResultOfPagedResultOfServiceCategoryDto" + } + } + } + } + } + } + }, + "/api/v1/catalog/option_groups": { + "get": { + "tags": [ + "Catalog" + ], + "operationId": "Catalog_OptionGroups", + "parameters": [ + { + "name": "category_id", + "x-originalName": "categoryId", + "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/ApiResultOfIReadOnlyListOfOptionGroupDto" + } + } + } + } + } + } + }, "/api/v1/customer_addresses/create": { "post": { "tags": [ @@ -3449,6 +4188,424 @@ ] } }, + "/api/v1/nurse_variants/create": { + "post": { + "tags": [ + "NurseVariants" + ], + "summary": "Creates a NurseVariant", + "operationId": "NurseVariants_Create", + "requestBody": { + "x-name": "command", + "description": "A NurseVariant representation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateVariantCommand" + } + } + }, + "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/ApiResultOfVariantDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/nurse_variants/update/{id}": { + "post": { + "tags": [ + "NurseVariants" + ], + "summary": "Updates a NurseVariant by unique id", + "operationId": "NurseVariants_Update", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "A NurseVariant representation", + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateVariantCommand" + } + } + }, + "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/ApiResultOfVariantDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/nurse_variants/set_active/{id}": { + "post": { + "tags": [ + "NurseVariants" + ], + "operationId": "NurseVariants_SetActive", + "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/SetVariantActiveCommand" + } + } + }, + "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/nurse_variants/list": { + "get": { + "tags": [ + "NurseVariants" + ], + "operationId": "NurseVariants_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/ApiResultOfPagedResultOfVariantDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/nurse_variants/get/{id}": { + "get": { + "tags": [ + "NurseVariants" + ], + "summary": "Retrieves a NurseVariant by unique id", + "operationId": "NurseVariants_Get", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "A unique id for the NurseVariant", + "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/ApiResultOfVariantDto" + } + } + } + } + } + } + }, "/api/v1/patients/create": { "post": { "tags": [ @@ -4581,6 +5738,338 @@ 500 ] }, + "ApiResultOfServiceCategoryDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/ServiceCategoryDto" + } + ] + } + } + } + ] + }, + "ServiceCategoryDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "nameFa": { + "type": "string" + }, + "nameEn": { + "type": "string" + }, + "descriptionFa": { + "type": "string", + "nullable": true + }, + "descriptionEn": { + "type": "string", + "nullable": true + }, + "iconKey": { + "type": "string", + "nullable": true + }, + "sortOrder": { + "type": "integer", + "format": "int32" + }, + "isActive": { + "type": "boolean" + } + } + }, + "CreateServiceCategoryCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "nameFa": { + "type": "string" + }, + "nameEn": { + "type": "string" + }, + "descriptionFa": { + "type": "string", + "nullable": true + }, + "descriptionEn": { + "type": "string", + "nullable": true + }, + "iconKey": { + "type": "string", + "nullable": true + }, + "sortOrder": { + "type": "integer", + "format": "int32" + } + } + }, + "UpdateServiceCategoryCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "nameFa": { + "type": "string" + }, + "nameEn": { + "type": "string" + }, + "descriptionFa": { + "type": "string", + "nullable": true + }, + "descriptionEn": { + "type": "string", + "nullable": true + }, + "iconKey": { + "type": "string", + "nullable": true + }, + "sortOrder": { + "type": "integer", + "format": "int32" + } + } + }, + "SetServiceCategoryActiveCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "isActive": { + "type": "boolean" + } + } + }, + "ApiResultOfOptionGroupDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/OptionGroupDto" + } + ] + } + } + } + ] + }, + "OptionGroupDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "serviceCategoryId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "nameFa": { + "type": "string" + }, + "nameEn": { + "type": "string" + }, + "isRequired": { + "type": "boolean" + }, + "sortOrder": { + "type": "integer", + "format": "int32" + }, + "isActive": { + "type": "boolean" + }, + "values": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OptionValueDto" + } + } + } + }, + "OptionValueDto": { + "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" + }, + "isActive": { + "type": "boolean" + } + } + }, + "CreateServiceOptionGroupCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "serviceCategoryId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "nameFa": { + "type": "string" + }, + "nameEn": { + "type": "string" + }, + "isRequired": { + "type": "boolean" + }, + "sortOrder": { + "type": "integer", + "format": "int32" + } + } + }, + "UpdateServiceOptionGroupCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "serviceCategoryId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "nameFa": { + "type": "string" + }, + "nameEn": { + "type": "string" + }, + "isRequired": { + "type": "boolean" + }, + "sortOrder": { + "type": "integer", + "format": "int32" + } + } + }, + "ApiResultOfOptionValueDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/OptionValueDto" + } + ] + } + } + } + ] + }, + "CreateServiceOptionValueCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "optionGroupId": { + "type": "integer", + "format": "int64" + }, + "nameFa": { + "type": "string", + "nullable": true + }, + "nameEn": { + "type": "string", + "nullable": true + }, + "sortOrder": { + "type": "integer", + "format": "int32" + } + } + }, + "UpdateServiceOptionValueCommand": { + "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" + }, + "isActive": { + "type": "boolean" + } + } + }, "ApiResultOfProvinceDto": { "allOf": [ { @@ -5098,6 +6587,72 @@ } } }, + "ApiResultOfPagedResultOfServiceCategoryDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/PagedResultOfServiceCategoryDto" + } + ] + } + } + } + ] + }, + "PagedResultOfServiceCategoryDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/ServiceCategoryDto" + } + }, + "total": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + } + } + }, + "ApiResultOfIReadOnlyListOfOptionGroupDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/OptionGroupDto" + } + } + } + } + ] + }, "ApiResultOfCustomerAddressDto": { "allOf": [ { @@ -6114,6 +7669,235 @@ } } }, + "ApiResultOfVariantDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/VariantDto" + } + ] + } + } + } + ] + }, + "VariantDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "serviceCategoryId": { + "type": "integer", + "format": "int64" + }, + "categoryNameFa": { + "type": "string", + "nullable": true + }, + "categoryNameEn": { + "type": "string", + "nullable": true + }, + "price": { + "type": "string", + "nullable": true + }, + "priceUnit": { + "type": "string", + "nullable": true + }, + "sessionCount": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "displayName": { + "type": "string", + "nullable": true + }, + "isActive": { + "type": "boolean" + }, + "options": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/VariantOptionDto" + } + } + } + }, + "VariantOptionDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "optionGroupId": { + "type": "integer", + "format": "int64" + }, + "groupNameFa": { + "type": "string", + "nullable": true + }, + "groupNameEn": { + "type": "string", + "nullable": true + }, + "optionValueId": { + "type": "integer", + "format": "int64" + }, + "valueNameFa": { + "type": "string", + "nullable": true + }, + "valueNameEn": { + "type": "string", + "nullable": true + } + } + }, + "CreateVariantCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "serviceCategoryId": { + "type": "integer", + "format": "int64" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VariantOptionSelection" + } + }, + "price": { + "type": "string" + }, + "priceUnit": { + "type": "string" + }, + "sessionCount": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "displayName": { + "type": "string", + "nullable": true + } + } + }, + "VariantOptionSelection": { + "type": "object", + "additionalProperties": false, + "properties": { + "optionGroupId": { + "type": "integer", + "format": "int64" + }, + "optionValueId": { + "type": "integer", + "format": "int64" + } + } + }, + "UpdateVariantCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "price": { + "type": "string" + }, + "priceUnit": { + "type": "string" + }, + "sessionCount": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "displayName": { + "type": "string", + "nullable": true + } + } + }, + "SetVariantActiveCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "isActive": { + "type": "boolean" + } + } + }, + "ApiResultOfPagedResultOfVariantDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/PagedResultOfVariantDto" + } + ] + } + } + } + ] + }, + "PagedResultOfVariantDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/VariantDto" + } + }, + "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 98efaf5..8752657 100644 --- a/dev/shared-working-context/backend/STATUS.md +++ b/dev/shared-working-context/backend/STATUS.md @@ -12,6 +12,31 @@ One block per completed backend phase. Newest at the top. Backend lane writes he - **Notes for frontend:** --> +## backend-phase-5 — Service catalog & nurse pricing variants — 2026-07-02 +- **Shipped:** five tables via one additive migration (`ServiceCatalogAndNurseVariants`) — new **`catalog`** + schema `ServiceCategories` / `ServiceOptionGroups` (nullable `service_category_id` = cross-category) / + `ServiceOptionValues` / `NurseServiceVariants` (`Price` **BIGINT IRR**, `PriceUnit`, `SessionCount?`, + `DisplayName`, `OptionSetHash`) / `NurseServiceVariantOptions` (`UNIQUE(variant_id, option_group_id)`); + seed of **5 categories** (`nameFa`+`nameEn`) via `HasData`. 16 CQRS slices across 3 controllers + (`catalog` public browse, `admin_catalog` CRUD + set-active, `nurse_variants` create/update/set-active/ + list/get). Duplicate-listing guard = `OptionSetHash` + filtered `UNIQUE(nurse_id, service_category_id, + option_set_hash) WHERE deleted_at IS NULL` + 409 pre-check. Public catalog reads cached behind a + `CatalogCache` generation token (invalidate on any admin write). Ships **`IVariantSnapshotSerializer`** + (pure, for b8). **No new seam.** +- **Contracts:** dev/contracts/domains/catalog.md + openapi snapshot refreshed (yes — 14 new + catalog/admin_catalog/nurse_variants paths; 71 total). +- **Mocked:** none — this phase mocks nothing and adds **no** `reports/mocks-registry.md` row. +- **Gate:** build clean (0 new code warnings) / tests green (122 pass: +10 handler/serializer unit, + +11 `Baya.Test.Api` integration). Migration verified to apply on a real SQL Server; swagger exposes all + b5 paths. Adversarial 4-dimension review: 0 confirmed findings. +- **Handoff:** backend/handoff/after-backend-phase-5.md +- **Notes for frontend:** the **variant is the bookable unit** (not the nurse). `price` is a **string of IRR + digits**; the total is `price` + `priceUnit` + `sessionCount`, never price alone. A **NULL-category option + group is cross-category** (render it under every category; required ones must be answered). Duplicate + identical listing → **409**; missing required dimension → **400**. `displayName` auto-generates (editable). + Deactivate, never delete. Routes are action-style POST (`admin_catalog/create_category`, + `nurse_variants/create`, …); groups/values are admin-authored (only categories are seeded). + ## 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 diff --git a/dev/shared-working-context/backend/handoff/after-backend-phase-5.md b/dev/shared-working-context/backend/handoff/after-backend-phase-5.md new file mode 100644 index 0000000..d63fb76 --- /dev/null +++ b/dev/shared-working-context/backend/handoff/after-backend-phase-5.md @@ -0,0 +1,82 @@ +# After backend-phase-5 — the service catalog & nurse pricing variants are live + +The two-tier service model the whole marketplace is priced and searched on now exists. Admins curate a +catalog skeleton (categories → configurable option groups → option values) that ships as **data, not +migrations**, and each nurse turns that skeleton into **variants** — the atomic bookable unit: a category + +one chosen value per dimension, at the nurse's own price and price unit. Contract: +[`dev/contracts/domains/catalog.md`](../../../contracts/domains/catalog.md); machine schema: +`dev/contracts/openapi/swagger.v1.json` (refreshed for b5). + +## What the frontend (f4-b5) can now build +- **Category grid / browse** — `GET api/v1/catalog/categories` (active, ordered, **paginated**, cached). + Five categories are seeded (Elderly, Post-Surgery, Infant, Chronic, Companionship) with `nameFa`+`nameEn`. +- **The nurse service-builder** — for a chosen category, `GET api/v1/catalog/option_groups?category_id=` + returns the **applicable** dimensions (the category's own groups **plus** every cross-category group), + each with `isRequired` and its active values. The nurse then `POST api/v1/nurse_variants/create` + `{ serviceCategoryId, options: [{ optionGroupId, optionValueId }], price, priceUnit, sessionCount?, displayName? }`. + Manage with `update/{id}`, `set_active/{id}`, `list`, `get/{id}`. Requires a nurse profile first + (b3 `nurse_profiles/upsert`). +- **Admin catalog console** — `admin_catalog/create_category|update_category/{id}|set_category_active/{id}`, + `create_option_group|update_option_group/{id}`, `create_option_value|update_option_value/{id}` (admin + token / dynamic-permission). +- **Public variant view** — `GET api/v1/nurse_variants/get/{id}` returns the public (active-only) projection + for anyone, backing a nurse-profile offerings list. + +## Rules baked into the API (don't fight them client-side) +- **The bookable unit is the variant, not the nurse.** Price/book against a specific variant. +- **`price` is a string of IRR-Rial digits** (e.g. `"8000000"`) — integer money, no floats, no Toman. The + **total is `price` + `priceUnit` + `sessionCount`**; never compute it from `price` alone. Parse with a + BigInt-safe helper, format for display, map `priceUnit` to an i18n label (never off the code). +- **A NULL-category option group is cross-category** — it applies to every category. Render the cross- + category groups in the builder for *every* category; the required ones must be answered. +- **All required dimensions must be answered**, **one value per dimension**, a value must belong to its + group. A missing required dimension → **400** (names it); a duplicate identical listing → **409**; surface + both cleanly. +- **`displayName` auto-generates** (category + chosen value labels) — show it, let the nurse override it. +- **Deactivate, never delete.** A deactivated variant stays in the nurse's own `list` (flagged + `isActive:false`) but is unbookable and 404s on the public `get`. +- **Every catalog row has `nameFa` (primary) + `nameEn`** — pick by locale, never label off a code. +- **Tenancy** — variants are strictly owner-scoped; another nurse's id on write → **404** (existence not + leaked). Catalog skeleton writes are **admin-only**. +- **Refresh after `select_role`** still applies (nurse scoping reads the role claim in the token). +- **Routes are action-style** (`admin_catalog/create_category`, `nurse_variants/create`, …), POST for + mutations, camelCase bodies — see the contract for the full list. + +## What b7 (search & matching) must read from the variant +The variant is a clean, projection-friendly source. b7 owns the denormalized `nurse_search_index` and the +`INurseSearch` seam (**not built here**). Its fan-out reads, per active variant: `service_category_id`, +`price`, `price_unit`, `is_active`, `nurse_id` — joined to the nurse's `nurse_service_areas` (b4) to emit one +index row per covered city/district, and to `nurse_profiles` for `is_verified`/accepting/gender/rating. The +**single write trigger points** to maintain that index are `CreateVariantCommand`, `SetVariantActiveCommand` +(and later option/price edits) — hook the index maintenance there. + +## What b8 (booking) consumes +`IVariantSnapshotSerializer` (`Application/Contracts/Common`, single real impl in `Application/Common`) is +ready: `string Serialize(VariantSnapshot)` emits the canonical `variant_snapshot_json` (category id + labels, +each `(group label, value label)`, `price` as a digit string, `price_unit`, `session_count`, `display_name`, +`variant_id`). b8 owns the `booking_requests.variant_snapshot_json` **column** and calls the serializer at +booking time so later variant edits/deactivation never mutate past bookings. The snapshot column is **not** +added here. + +## Schema / migration +Migration **`20260702132758_ServiceCatalogAndNurseVariants`** (applies on startup), new **`catalog`** schema: +`ServiceCategories`, `ServiceOptionGroups` (nullable `ServiceCategoryId` = cross-category), +`ServiceOptionValues`, `NurseServiceVariants` (`Price` **BIGINT**, `PriceUnit` code, `SessionCount?`, +`DisplayName`, `OptionSetHash`), `NurseServiceVariantOptions`. Constraints: `UNIQUE(VariantId, OptionGroupId)` +(one value per dimension); filtered `UNIQUE(NurseId, ServiceCategoryId, OptionSetHash) WHERE DeletedAt IS +NULL` (duplicate-listing backstop); soft-delete query filters. Seed: five categories (`nameFa`+`nameEn`), +ids 1–5. Option groups/values are admin-authored data (not seeded). + +## Deferred to later phases (do not build against these yet) +- **`nurse_search_index`, `INurseSearch`, the search query & index fan-out** → **b7** (variant writes are the + clean trigger point; the variant shape is projection-ready). +- **`variant_snapshot_json` persistence** → **b8** (the serializer is shipped and unit-tested here). +- **`nurse_availability_slots` / `nurse_availability_exceptions`** → **deferred** (soft scheduling guidance, + not on the money/safety path) — not built. +- **Holiday/surge pricing, a Companionship *tier* pricing model, tiered per-category commission** → + **deferred**. Companionship ships only as a seeded category (data), not a special pricing path. + +## What's mocked +**Nothing.** Catalog and variant data are fully owned by Balinyaar's DB — this phase introduces **no** +cross-cutting seam and adds **no** row to `reports/mocks-registry.md`. It reuses `ICacheService` (b0) for the +public catalog reads with invalidate-on-mutation. diff --git a/dev/shared-working-context/reports/backend-phase-5-report.md b/dev/shared-working-context/reports/backend-phase-5-report.md new file mode 100644 index 0000000..339d520 --- /dev/null +++ b/dev/shared-working-context/reports/backend-phase-5-report.md @@ -0,0 +1,103 @@ +# Backend Phase 5 — Service catalog & nurse pricing variants — report + +**Status:** complete · build clean (0 new code warnings) · `dotnet test Baya.sln` green (122 pass: ++21 over b4's 101 — 10 new handler/serializer unit tests, +11 API integration & adversarially reviewed). +**Nothing is mocked** in this phase. + +## What was built +The two-tier service model the whole marketplace is priced/searched on — one additive migration +(`20260702132758_ServiceCatalogAndNurseVariants`, new **`catalog`** schema), five tables, 14 endpoints across +3 controllers, plus the b8 snapshot serializer. + +- **Entities** (`Domain/Entities/Catalog/`): `ServiceCategory`, `ServiceOptionGroup` (nullable + `ServiceCategoryId` = cross-category), `ServiceOptionValue`, `NurseServiceVariant` (`Price` **BIGINT IRR**, + `PriceUnit`, `SessionCount?`, `DisplayName`, `OptionSetHash`), `NurseServiceVariantOption`, and the closed + `PriceUnits` set (`per_hour`/`per_session`/`per_half_day`/`per_day`/`per_24h`). +- **EF configs + seed** (`Persistence/Configuration/CatalogConfig/`): soft-delete query filters; + `UNIQUE(variant_id, option_group_id)`; filtered `UNIQUE(nurse_id, service_category_id, option_set_hash) + WHERE deleted_at IS NULL`; `(is_active, sort_order)` / `(service_category_id, sort_order)` / + `(option_group_id, sort_order)` / `(nurse_id, is_active)` / `(service_category_id)` indexes. **Five seed + categories** (`nameFa`+`nameEn`, ids 1–5) via `HasData`. +- **Admin catalog CQRS** (`Features/Catalog/`): `CreateServiceCategory`/`UpdateServiceCategory`/ + `SetServiceCategoryActive`, `CreateServiceOptionGroup`/`UpdateServiceOptionGroup`, + `CreateServiceOptionValue`/`UpdateServiceOptionValue`, and the public cached `GetCatalogCategories` + (paginated) / `GetCategoryOptionGroups` (applicable = own + cross-category). Every mutation invalidates the + `CatalogCache` generation token. +- **Nurse variant CQRS** (`Features/Variants/`): `CreateVariant` (required-group enforcement incl. + cross-category, one-value-per-dimension, value-in-group, duplicate-listing pre-check + auto display-name), + `UpdateVariant` (price/unit/session/display; option-set immutable), `SetVariantActive` (deactivate, never + delete), `ListMyVariants` (active + inactive), `GetVariant` (owner/admin full · public active-only). +- **`OptionSetHash`** helper (deterministic, order-independent SHA-256) + **`IVariantSnapshotSerializer`** + (pure, singleton; canonical `variant_snapshot_json` for b8). +- **Controllers** (`Controllers/V1/`): `AdminCatalogController` (dynamic-permission), `CatalogController` + (public), `NurseVariantsController` (`[Authorize]`; `get/{id}` is `[AllowAnonymous]`). + +## What is now testable — and exactly how (the phase §7 steps) +Run the API (`dotnet run --project src/API/Baya.Web.Api/...`) against a reachable SQL Server; use Swagger/curl. +1. **Catalog seeded** — `GET /api/v1/catalog/categories` → `200`, five categories, `nameFa`+`nameEn`, ordered + by `sortOrder`, active only. +2. **Admin builds a dimension** — as admin, `POST /api/v1/admin_catalog/create_option_group` + `{ serviceCategoryId: 1, nameFa: "نوع شیفت", nameEn: "Shift type", isRequired: true, sortOrder: 1 }` → `200`; + `POST /api/v1/admin_catalog/create_option_value` twice → `200`; + `GET /api/v1/catalog/option_groups?category_id=1` → the required group **plus any cross-category groups**, + each with its values. +3. **Nurse builds a valid variant** — `POST /api/v1/nurse_variants/create` + `{ serviceCategoryId: 1, options: [{ optionGroupId, optionValueId }], price: "8000000", priceUnit: "per_24h" }` + → `200`, `isActive: true`, auto `displayName` = category + value labels. +4. **Duplicate identical listing** — repeat the exact create → clean **`409`** (not a `500`). +5. **Missing required dimension** — create with `options: []` → **`400`** naming the missing group. +6. **One value per dimension** — two values for the same group in one create → rejected (handler + the + `UNIQUE(variant_id, option_group_id)` backstop). +7. **List active + inactive** — `GET /api/v1/nurse_variants/list`; then + `POST /api/v1/nurse_variants/set_active/{id}` `{ isActive: false }` → the deactivated variant still appears, + flagged `isActive: false` and unbookable; the row is **never** hard-deleted. +8. **Tenancy** — a *different* nurse `POST /api/v1/nurse_variants/update/{id}` on the first nurse's variant → + **`404`** (existence not leaked). +9. **Snapshot serializer** — unit-tested: the JSON carries the category labels, each option label, `price` + (as a digit string), `priceUnit`, and `sessionCount`. + +Automated coverage: `Baya.Test.Foundation/Catalog/` (CreateVariant valid/missing-required/duplicate/ +one-value-per-dimension/value-not-in-group/inactive-category/non-nurse/override; admin category cache +invalidation + parent checks + cross-category null group; serializer) and `Baya.Test.Api/` +(`CatalogPublicApiTests`, `NurseVariantsApiTests` — full lifecycle incl. 409/400/tenancy-404/public-get/401). + +## Adversarial review +A 4-dimension review (tenancy/authorization, EAV/cross-category/required-group, money integrity, EF-translation/ +caching/soft-delete) with independent per-finding verification ran over the diff (159 tool-uses, ~382k tokens): +**0 confirmed findings**. Spot-checked by hand: cross-nurse `get/{id}` returns the public (never full) view; +the duplicate hash includes cross-category selections; missing a required cross-category group → 400. + +## Contracts produced / consumed +- **Produced:** [`dev/contracts/domains/catalog.md`](../../contracts/domains/catalog.md) (routes, shapes, + `price_unit` enum, IRR-string money, `409`/`400` failure cases, examples). `swagger.v1.json` **refreshed** + (71 paths total; +14 catalog/variant paths) — verified the API boots and applies the migration on a real + SQL Server. +- **Consumed:** `nurse_profiles` (b3), `ICacheService`/CQRS/`OperationResult`/`BaseController` (b0), + admin dynamic-permission policy (b1/b2). No geography coupling (b7 joins the two later). + +## Mocks +**None.** This phase introduces no cross-cutting seam and adds **no** row to +[`mocks-registry.md`](mocks-registry.md) — stated here so the next agent doesn't go looking. +`IVariantSnapshotSerializer` is an internal application contract with a single real implementation (not a +mock seam). `ICacheService` is reused (already 🟡 from b0), not redefined. + +## Follow-ups for later phases +- **b7 (search & matching)** — owns `nurse_search_index`, `INurseSearch`, the search query, and index + fan-out. Reads per active variant: `service_category_id`, `price`, `price_unit`, `is_active`, `nurse_id`, + fanned across the nurse's `nurse_service_areas` (b4). The maintenance trigger points are + `CreateVariantCommand` / `SetVariantActiveCommand` (and future option/price edits). +- **b8 (booking)** — owns `booking_requests.variant_snapshot_json`; calls `IVariantSnapshotSerializer` at + booking time. The serializer is shipped and unit-tested here. +- **Deferred (not built):** `nurse_availability_slots`/`_exceptions` (soft guidance); holiday/surge pricing; + a Companionship *tier* pricing model (ships only as a seeded category); tiered per-category commission. + +## Notable decisions (recorded in product/eng docs, not invented) +- Routes are **action-style POST** with camelCase bodies (matching b3/b4), not the PUT/PATCH the phase table + sketched — documented in the contract's routing note. +- `update_option_value` does **not** re-parent a value to another group (would silently change the meaning of + variants that already answered with it). +- `price` crosses the wire as a **string of digits** (`money-and-types.md`); the DTO/command use `string`. +- The `OptionSetHash` + filtered-unique duplicate-listing strategy is noted as a reusable pattern in + `server/CONVENTIONS.md` §6. +No new *business* rules were discovered — the product docs (`business/03`, `data-model/03`) already matched; +left unchanged. diff --git a/server/CLAUDE.md b/server/CLAUDE.md index df36993..69a450a 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, 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) +│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), + 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; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + 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 + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses), 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 + public Catalog + admin AdminCatalog + nurse NurseVariants), 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 @@ -168,6 +168,32 @@ via `HasData` (b1 path): 31 provinces + their capital cities (covers the white-s - **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. +**Service catalog & nurse pricing variants (backend-phase-5).** A new **`catalog` schema** holds the two-tier +service model. The **admin skeleton** — `ServiceCategories` → `ServiceOptionGroups` → `ServiceOptionValues` — +is intentionally **EAV/data, not code**: an admin adds a category or a pricing dimension as rows, never a +migration (the only closed code enum in the area is `PriceUnits`). A `ServiceOptionGroups.ServiceCategoryId = +NULL` marks a **cross-category** dimension that applies to every category. The **nurse layer** — +`NurseServiceVariants` (the atomic **bookable unit**: FK `nurse_profiles` + category + `Price` **BIGINT IRR** ++ `PriceUnit` code + `SessionCount?` + auto-generated-but-editable `DisplayName`) + `NurseServiceVariantOptions` +(one row per answered dimension, `UNIQUE(variant_id, option_group_id)`) — turns the skeleton into priced +offerings. Features under `Baya.Application/Features/{Catalog|Variants}/`; configs + +seed in `Persistence/Configuration/CatalogConfig/`; per-domain repos (`ICatalogRepository`, +`INurseServiceVariantRepository`) on `IUnitOfWork`. Load-bearing rules: +- **The bookable unit is the variant, not the nurse.** b7 (search) and b8 (booking) operate on a variant; + keep it a clean projectable source. `price` is IRR `BIGINT` (no floats) and crosses the wire as a digit + string; the engagement total is `price` + `price_unit` + `session_count`, never `price` alone. +- **Duplicate-listing guard** = a deterministic `OptionSetHash` (see `CONVENTIONS.md`) + a filtered + `UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULL` backstop, plus a friendly + pre-check (409) — a multi-row option-set can't be a plain composite unique. +- **Applicable groups = the category's own groups + every cross-category (NULL) group** everywhere (public + browse, required-group validation, duplicate guard). All required groups must be answered; one value per + dimension; deactivate, never hard-delete (soft-delete query filters). +- **Public catalog reads are cached** through `ICacheService` behind a `CatalogCache` generation-token scheme; + any admin catalog write bumps the token. +- **`IVariantSnapshotSerializer`** (Application contract, single real impl in `Application/Common`) emits the + canonical `variant_snapshot_json` and is **consumed by b8** (which owns the `booking_requests` column); + this phase ships and unit-tests it but persists nothing. `nurse_search_index` is **b7's** (not built here). + **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/CONVENTIONS.md b/server/CONVENTIONS.md index f7084a4..7ba0380 100644 --- a/server/CONVENTIONS.md +++ b/server/CONVENTIONS.md @@ -300,6 +300,18 @@ Wire `ICurrentUser` (HTTP context accessor wrapped in an interface, registered S Every monetary value is **IRR Rials stored as `long` / `BIGINT`**. There is **no float/decimal path** on money — not in entities, DTOs, the API, or arithmetic. Toman is display-only and converts to/from Rials **only** inside a provider adapter at its boundary, never in domain or shared code. If a money value object is introduced later it must be integer-only. The three booking amounts always satisfy `gross = commission + payout`. +### Deterministic set-hash for multi-row uniqueness + +When "no two rows may share the same *set* of child rows" must be enforced (e.g. a nurse can't list two +identical variants — same category + identical answered option-set), a plain composite unique index can't +express it because the set spans multiple rows. Reduce the set to a single comparable column with +**`Baya.Application.Common.OptionSetHash.Compute(pairs)`** (backend-phase-5): it sorts the `(long, long)` +pairs and SHA-256s them to a stable 64-char hex hash that is **order-independent** (identical sets always +collide). Persist it (`NVARCHAR(64)`) and back it with a **filtered unique index** (e.g. +`UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULL`) as the race-safe backstop, +with a handler pre-check for the friendly `409`. Reuse this helper for any future "same set of ids" guard; +do **not** reuse `IFieldEncryptor.Hash` (that is for PII-column equality lookups). + --- ## 7. Validation diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/AdminCatalogController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/AdminCatalogController.cs new file mode 100644 index 0000000..56e4d85 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/AdminCatalogController.cs @@ -0,0 +1,61 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Catalog.Commands.CreateServiceCategory; +using Baya.Application.Features.Catalog.Commands.CreateServiceOptionGroup; +using Baya.Application.Features.Catalog.Commands.CreateServiceOptionValue; +using Baya.Application.Features.Catalog.Commands.SetServiceCategoryActive; +using Baya.Application.Features.Catalog.Commands.UpdateServiceCategory; +using Baya.Application.Features.Catalog.Commands.UpdateServiceOptionGroup; +using Baya.Application.Features.Catalog.Commands.UpdateServiceOptionValue; +using Baya.Application.Models.Catalog; +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 catalog skeleton (categories + option groups/values; no delete)")] +public sealed class AdminCatalogController(ISender sender) : BaseController +{ + [HttpPost("[action]")] + [ProducesOkApiResponseType] + public async Task CreateCategory(CreateServiceCategoryCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpPost("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task UpdateCategory(long id, UpdateServiceCategoryCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command with { Id = id }, cancellationToken)); + + [HttpPost("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task SetCategoryActive(long id, SetServiceCategoryActiveCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command with { Id = id }, cancellationToken)); + + [HttpPost("[action]")] + [ProducesOkApiResponseType] + public async Task CreateOptionGroup(CreateServiceOptionGroupCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpPost("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task UpdateOptionGroup(long id, UpdateServiceOptionGroupCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command with { Id = id }, cancellationToken)); + + [HttpPost("[action]")] + [ProducesOkApiResponseType] + public async Task CreateOptionValue(CreateServiceOptionValueCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpPost("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task UpdateOptionValue(long id, UpdateServiceOptionValueCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command with { Id = id }, cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/CatalogController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/CatalogController.cs new file mode 100644 index 0000000..8fcff9b --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/CatalogController.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Catalog.Queries.GetCatalogCategories; +using Baya.Application.Features.Catalog.Queries.GetCategoryOptionGroups; +using Baya.Application.Models.Catalog; +using Baya.Application.Models.Common; +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 catalog browse: active categories and a category's applicable option groups")] +public sealed class CatalogController(ISender sender) : BaseController +{ + [HttpGet("[action]")] + [ProducesOkApiResponseType>] + public async Task Categories([FromQuery] GetCatalogCategoriesQuery query, CancellationToken cancellationToken) + => OperationResult(await sender.Send(query, cancellationToken)); + + [HttpGet("[action]")] + [ProducesOkApiResponseType>] + public async Task OptionGroups([FromQuery(Name = "category_id")] long categoryId, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new GetCategoryOptionGroupsQuery(categoryId), cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/NurseVariantsController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/NurseVariantsController.cs new file mode 100644 index 0000000..d139180 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/NurseVariantsController.cs @@ -0,0 +1,51 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Variants.Commands.CreateVariant; +using Baya.Application.Features.Variants.Commands.SetVariantActive; +using Baya.Application.Features.Variants.Commands.UpdateVariant; +using Baya.Application.Features.Variants.Queries.GetVariant; +using Baya.Application.Features.Variants.Queries.ListMyVariants; +using Baya.Application.Models.Catalog; +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 nurse's priced service variants (the bookable unit)")] +public sealed class NurseVariantsController(ISender sender) : BaseController +{ + [HttpPost("[action]")] + [ProducesOkApiResponseType] + public async Task Create(CreateVariantCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpPost("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task Update(long id, UpdateVariantCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command with { Id = id }, cancellationToken)); + + [HttpPost("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task SetActive(long id, SetVariantActiveCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command with { Id = id }, cancellationToken)); + + [HttpGet("[action]")] + [ProducesOkApiResponseType>] + public async Task List([FromQuery] ListMyVariantsQuery query, CancellationToken cancellationToken) + => OperationResult(await sender.Send(query, cancellationToken)); + + // Owner/admin get the full view; any other caller (public nurse-profile view) gets active-only. + [AllowAnonymous] + [HttpGet("[action]/{id}")] + [ProducesOkApiResponseType] + public async Task Get(long id, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new GetVariantQuery(id), cancellationToken)); +} diff --git a/server/src/Core/Baya.Application/Common/OptionSetHash.cs b/server/src/Core/Baya.Application/Common/OptionSetHash.cs new file mode 100644 index 0000000..93c2e1d --- /dev/null +++ b/server/src/Core/Baya.Application/Common/OptionSetHash.cs @@ -0,0 +1,26 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Baya.Application.Common; + +/// +/// Deterministic hash of a variant's answered option-set — the key behind the duplicate-listing filtered +/// unique index (UNIQUE(nurse_id, service_category_id, option_set_hash)). Because the option-set is +/// multi-row, a plain composite unique index can't express "same set of choices"; hashing the sorted +/// (group_id, value_id) pairs reduces it to one comparable column. Sorting makes the hash order- +/// independent; the same set of choices always yields the same 64-char hex hash. An empty option-set +/// (a category with no answered groups) hashes to a stable value, so two such variants still collide. +/// +public static class OptionSetHash +{ + public static string Compute(IEnumerable<(long GroupId, long ValueId)> pairs) + { + var canonical = string.Join( + "|", + pairs.OrderBy(p => p.GroupId).ThenBy(p => p.ValueId) + .Select(p => $"{p.GroupId}:{p.ValueId}")); + + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical)); + return Convert.ToHexString(hash).ToLowerInvariant(); + } +} diff --git a/server/src/Core/Baya.Application/Common/VariantSnapshotSerializer.cs b/server/src/Core/Baya.Application/Common/VariantSnapshotSerializer.cs new file mode 100644 index 0000000..7b09e76 --- /dev/null +++ b/server/src/Core/Baya.Application/Common/VariantSnapshotSerializer.cs @@ -0,0 +1,56 @@ +using System.Globalization; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Unicode; +using Baya.Application.Contracts.Common; +using Baya.Application.Models.Catalog; + +namespace Baya.Application.Common; + +/// +/// Canonical implementation of . Emits a stable, camelCase JSON +/// object carrying the category id + labels, each resolved (group label, value label), the price +/// (as a string of digits), price unit, session count, display name, and the variant id. Property order is +/// fixed (declaration order) so the output is deterministic for a given input. +/// +public sealed class VariantSnapshotSerializer : IVariantSnapshotSerializer +{ + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + // Persian labels must land in the snapshot as readable text, not \uXXXX escapes; the encoder still + // escapes the HTML-sensitive ASCII characters so the stored JSON stays safe to embed. + Encoder = JavaScriptEncoder.Create(UnicodeRanges.All), + WriteIndented = false + }; + + public string Serialize(VariantSnapshot snapshot) + { + // Money crosses the snapshot as a string of IRR-Rial digits (integer money, no floats). Invariant + // culture so the digits are always ASCII regardless of the ambient locale. + var payload = new + { + variantId = snapshot.VariantId, + serviceCategoryId = snapshot.ServiceCategoryId, + categoryNameFa = snapshot.CategoryNameFa, + categoryNameEn = snapshot.CategoryNameEn, + price = snapshot.Price.ToString(CultureInfo.InvariantCulture), + priceUnit = snapshot.PriceUnit, + sessionCount = snapshot.SessionCount, + displayName = snapshot.DisplayName, + options = snapshot.Options.Select(o => new + { + o.OptionGroupId, + o.GroupNameFa, + o.GroupNameEn, + o.OptionValueId, + o.ValueNameFa, + o.ValueNameEn + }) + }; + + return JsonSerializer.Serialize(payload, Options); + } +} diff --git a/server/src/Core/Baya.Application/Contracts/Common/IVariantSnapshotSerializer.cs b/server/src/Core/Baya.Application/Contracts/Common/IVariantSnapshotSerializer.cs new file mode 100644 index 0000000..97762d4 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Common/IVariantSnapshotSerializer.cs @@ -0,0 +1,16 @@ +using Baya.Application.Models.Catalog; + +namespace Baya.Application.Contracts.Common; + +/// +/// Emits the canonical variant_snapshot_json that Booking (backend-phase-8) freezes onto a +/// booking_requests row so later variant edits/deactivation never mutate past bookings, disputes, or +/// invoices. A pure function — no I/O, no state. This phase ships and unit-tests it; b8 persists its output. +/// Not an external-service seam: it has a single real implementation. +/// +public interface IVariantSnapshotSerializer +{ + /// Serialize a variant + its resolved options as they are at serialize time. Price is emitted + /// as a string of IRR-Rial digits (integer money, no floats). + string Serialize(VariantSnapshot snapshot); +} diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/ICatalogRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/ICatalogRepository.cs new file mode 100644 index 0000000..b7b563d --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Persistence/ICatalogRepository.cs @@ -0,0 +1,43 @@ +#nullable enable +using Baya.Application.Models.Catalog; +using Baya.Domain.Entities.Catalog; + +namespace Baya.Application.Contracts.Persistence; + +/// +/// The admin catalog skeleton (categories → option groups → option values) plus the public browse reads. +/// Reads are projected + no-tracking (callers cache them); admin getters are tracked (include inactive, +/// exclude soft-deleted) for edit/toggle. The applicable-groups read returns a category's own groups +/// plus every cross-category (NULL-category) group — the load-bearing EAV rule. +/// +public interface ICatalogRepository +{ + /// All active categories, ordered by sort order — cached by the caller. + Task> ListActiveCategoriesAsync(CancellationToken cancellationToken); + + /// The category's own active groups plus every cross-category (NULL) active group, each with + /// its active values, ordered by sort order. Empty is valid (no dimensions defined yet). + Task> GetApplicableGroupsAsync(long categoryId, CancellationToken cancellationToken); + + /// Active-only category projection for variant creation — null if missing or deactivated. + Task GetActiveCategoryAsync(long id, CancellationToken cancellationToken); + + /// A single group projected with its active values — the honest response for a group mutation + /// (a freshly created group returns an empty value list). Null if the group is absent/soft-deleted. + Task GetGroupDtoAsync(long id, CancellationToken cancellationToken); + + // Admin: tracked lookups (include inactive, exclude soft-deleted) for edit/toggle. + Task GetCategoryAsync(long id, CancellationToken cancellationToken); + Task GetGroupAsync(long id, CancellationToken cancellationToken); + Task GetValueAsync(long id, CancellationToken cancellationToken); + + /// Whether a non-soft-deleted category exists (any active state) — parent check for a group. + Task CategoryExistsAsync(long id, CancellationToken cancellationToken); + + /// Whether a non-soft-deleted group exists — parent check for a value. + Task GroupExistsAsync(long id, CancellationToken cancellationToken); + + Task AddCategoryAsync(ServiceCategory category, CancellationToken cancellationToken); + Task AddGroupAsync(ServiceOptionGroup group, CancellationToken cancellationToken); + Task AddValueAsync(ServiceOptionValue value, CancellationToken cancellationToken); +} diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/INurseServiceVariantRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/INurseServiceVariantRepository.cs new file mode 100644 index 0000000..5649c33 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Persistence/INurseServiceVariantRepository.cs @@ -0,0 +1,36 @@ +#nullable enable +using Baya.Application.Models.Catalog; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.Catalog; + +namespace Baya.Application.Contracts.Persistence; + +/// +/// A nurse's priced offerings — the atomic bookable unit. Writes go through the owning-nurse tenancy check; +/// reads project to with resolved category/option labels. The duplicate-listing +/// guard is a pre-check here plus the filtered unique index backstop in the EF configuration. +/// +public interface INurseServiceVariantRepository +{ + Task AddAsync(NurseServiceVariant variant, CancellationToken cancellationToken); + + /// Tracked, tenancy-scoped getter for owner scalar edits/toggle. Null if not owned/absent — + /// existence of another nurse's variant is never leaked. + Task GetOwnedAsync(long id, long nurseId, CancellationToken cancellationToken); + + /// Duplicate-listing pre-check: a non-deleted variant with this exact option-set already exists + /// for the nurse+category. skips the variant being edited. + Task DuplicateHashExistsAsync(long nurseId, long serviceCategoryId, string optionSetHash, long? excludeVariantId, CancellationToken cancellationToken); + + /// The nurse's own offerings (active + inactive), paginated, active-first, resolved labels. + Task> ListMineAsync(long nurseId, int page, int pageSize, CancellationToken cancellationToken); + + /// Full projection for the owning nurse — any status. Null if not owned/absent. + Task GetOwnedProjectedAsync(long id, long nurseId, CancellationToken cancellationToken); + + /// Full projection for admin — any status. Null if absent. + Task GetProjectedAsync(long id, CancellationToken cancellationToken); + + /// Public-safe projection: an active variant only. Null when missing/inactive. + Task GetPublicProjectedAsync(long id, 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 d858647..7c7098d 100644 --- a/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs @@ -12,6 +12,8 @@ public interface IUnitOfWork public IGeoRepository GeoRepository { get; } public INurseServiceAreaRepository NurseServiceAreaRepository { get; } public ICustomerAddressRepository CustomerAddressRepository { get; } + public ICatalogRepository CatalogRepository { get; } + public INurseServiceVariantRepository NurseServiceVariantRepository { get; } Task CommitAsync(); ValueTask RollBackAsync(); } diff --git a/server/src/Core/Baya.Application/Features/Catalog/CatalogCache.cs b/server/src/Core/Baya.Application/Features/Catalog/CatalogCache.cs new file mode 100644 index 0000000..3c9ada0 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Catalog/CatalogCache.cs @@ -0,0 +1,31 @@ +#nullable enable +using Baya.Application.Contracts.Common; + +namespace Baya.Application.Features.Catalog; + +/// +/// Cache-key scheme for the read-heavy public catalog lookups (categories + a category's option groups). +/// Every data key is namespaced by a generation token; any admin catalog mutation bumps the token, which +/// orphans all prior catalog entries in one move (they lapse by TTL). Mirrors the geo generation-token +/// scheme so cascade invalidation stays trivial and correct — deactivating a category or editing a group +/// instantly refreshes the whole namespace without enumerating child keys. +/// +internal static class CatalogCache +{ + private const string VersionKey = "catalog:version"; + + // Catalog 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 CategoriesKey(string version) => $"catalog:{version}:categories"; + + public static string OptionGroupsKey(string version, long categoryId) => $"catalog:{version}:groups:{categoryId}"; + + private static string NewToken() => Guid.NewGuid().ToString("N"); +} diff --git a/server/src/Core/Baya.Application/Features/Catalog/Commands/CreateServiceCategory/CreateServiceCategoryCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Catalog/Commands/CreateServiceCategory/CreateServiceCategoryCommand.Handler.cs new file mode 100644 index 0000000..d74e50c --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Catalog/Commands/CreateServiceCategory/CreateServiceCategoryCommand.Handler.cs @@ -0,0 +1,35 @@ +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Catalog; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.Catalog; +using Mediator; + +namespace Baya.Application.Features.Catalog.Commands.CreateServiceCategory; + +internal sealed class CreateServiceCategoryCommandHandler(IUnitOfWork unitOfWork, ICacheService cache) + : IRequestHandler> +{ + public async ValueTask> Handle(CreateServiceCategoryCommand request, CancellationToken cancellationToken) + { + var category = new ServiceCategory + { + NameFa = request.NameFa, + NameEn = request.NameEn, + DescriptionFa = request.DescriptionFa, + DescriptionEn = request.DescriptionEn, + IconKey = request.IconKey, + SortOrder = request.SortOrder, + IsActive = true + }; + + await unitOfWork.CatalogRepository.AddCategoryAsync(category, cancellationToken); + await unitOfWork.CommitAsync(); + + await CatalogCache.InvalidateAsync(cache, cancellationToken); + + return OperationResult.SuccessResult(new ServiceCategoryDto( + category.Id, category.NameFa, category.NameEn, category.DescriptionFa, category.DescriptionEn, + category.IconKey, category.SortOrder, category.IsActive)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Catalog/Commands/CreateServiceCategory/CreateServiceCategoryCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Catalog/Commands/CreateServiceCategory/CreateServiceCategoryCommand.Validator.cs new file mode 100644 index 0000000..4b4ea10 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Catalog/Commands/CreateServiceCategory/CreateServiceCategoryCommand.Validator.cs @@ -0,0 +1,15 @@ +using FluentValidation; + +namespace Baya.Application.Features.Catalog.Commands.CreateServiceCategory; + +public sealed class CreateServiceCategoryCommandValidator : AbstractValidator +{ + public CreateServiceCategoryCommandValidator() + { + RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150); + RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150); + RuleFor(x => x.DescriptionFa).MaximumLength(1000); + RuleFor(x => x.DescriptionEn).MaximumLength(1000); + RuleFor(x => x.IconKey).MaximumLength(100); + } +} diff --git a/server/src/Core/Baya.Application/Features/Catalog/Commands/CreateServiceCategory/CreateServiceCategoryCommand.cs b/server/src/Core/Baya.Application/Features/Catalog/Commands/CreateServiceCategory/CreateServiceCategoryCommand.cs new file mode 100644 index 0000000..75186a2 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Catalog/Commands/CreateServiceCategory/CreateServiceCategoryCommand.cs @@ -0,0 +1,15 @@ +#nullable enable +using Baya.Application.Models.Catalog; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Catalog.Commands.CreateServiceCategory; + +/// Admin: add a top-level care category (data, not code). Both labels required. Invalidates cache. +public record CreateServiceCategoryCommand( + string NameFa, + string NameEn, + string? DescriptionFa, + string? DescriptionEn, + string? IconKey, + int SortOrder) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Catalog/Commands/CreateServiceOptionGroup/CreateServiceOptionGroupCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Catalog/Commands/CreateServiceOptionGroup/CreateServiceOptionGroupCommand.Handler.cs new file mode 100644 index 0000000..819cb45 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Catalog/Commands/CreateServiceOptionGroup/CreateServiceOptionGroupCommand.Handler.cs @@ -0,0 +1,37 @@ +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Catalog; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.Catalog; +using Mediator; + +namespace Baya.Application.Features.Catalog.Commands.CreateServiceOptionGroup; + +internal sealed class CreateServiceOptionGroupCommandHandler(IUnitOfWork unitOfWork, ICacheService cache) + : IRequestHandler> +{ + public async ValueTask> Handle(CreateServiceOptionGroupCommand request, CancellationToken cancellationToken) + { + if (request.ServiceCategoryId is { } categoryId + && !await unitOfWork.CatalogRepository.CategoryExistsAsync(categoryId, cancellationToken)) + return OperationResult.FailureResult(nameof(request.ServiceCategoryId), "Category not found."); + + var group = new ServiceOptionGroup + { + ServiceCategoryId = request.ServiceCategoryId, + NameFa = request.NameFa, + NameEn = request.NameEn, + IsRequired = request.IsRequired, + SortOrder = request.SortOrder, + IsActive = true + }; + + await unitOfWork.CatalogRepository.AddGroupAsync(group, cancellationToken); + await unitOfWork.CommitAsync(); + + await CatalogCache.InvalidateAsync(cache, cancellationToken); + + var dto = await unitOfWork.CatalogRepository.GetGroupDtoAsync(group.Id, cancellationToken); + return OperationResult.SuccessResult(dto!); + } +} diff --git a/server/src/Core/Baya.Application/Features/Catalog/Commands/CreateServiceOptionGroup/CreateServiceOptionGroupCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Catalog/Commands/CreateServiceOptionGroup/CreateServiceOptionGroupCommand.Validator.cs new file mode 100644 index 0000000..dcd9b3f --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Catalog/Commands/CreateServiceOptionGroup/CreateServiceOptionGroupCommand.Validator.cs @@ -0,0 +1,14 @@ +using FluentValidation; + +namespace Baya.Application.Features.Catalog.Commands.CreateServiceOptionGroup; + +public sealed class CreateServiceOptionGroupCommandValidator : AbstractValidator +{ + public CreateServiceOptionGroupCommandValidator() + { + RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150); + RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150); + // Null is the deliberate cross-category case; only a supplied id must be positive. + RuleFor(x => x.ServiceCategoryId).GreaterThan(0).When(x => x.ServiceCategoryId.HasValue); + } +} diff --git a/server/src/Core/Baya.Application/Features/Catalog/Commands/CreateServiceOptionGroup/CreateServiceOptionGroupCommand.cs b/server/src/Core/Baya.Application/Features/Catalog/Commands/CreateServiceOptionGroup/CreateServiceOptionGroupCommand.cs new file mode 100644 index 0000000..24e9931 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Catalog/Commands/CreateServiceOptionGroup/CreateServiceOptionGroupCommand.cs @@ -0,0 +1,17 @@ +#nullable enable +using Baya.Application.Models.Catalog; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Catalog.Commands.CreateServiceOptionGroup; + +/// +/// Admin: add a pricing dimension. == null makes it +/// cross-category (applies to every category). Invalidates the catalog cache. +/// +public record CreateServiceOptionGroupCommand( + long? ServiceCategoryId, + string NameFa, + string NameEn, + bool IsRequired, + int SortOrder) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Catalog/Commands/CreateServiceOptionValue/CreateServiceOptionValueCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Catalog/Commands/CreateServiceOptionValue/CreateServiceOptionValueCommand.Handler.cs new file mode 100644 index 0000000..494f2e9 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Catalog/Commands/CreateServiceOptionValue/CreateServiceOptionValueCommand.Handler.cs @@ -0,0 +1,35 @@ +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Catalog; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.Catalog; +using Mediator; + +namespace Baya.Application.Features.Catalog.Commands.CreateServiceOptionValue; + +internal sealed class CreateServiceOptionValueCommandHandler(IUnitOfWork unitOfWork, ICacheService cache) + : IRequestHandler> +{ + public async ValueTask> Handle(CreateServiceOptionValueCommand request, CancellationToken cancellationToken) + { + if (!await unitOfWork.CatalogRepository.GroupExistsAsync(request.OptionGroupId, cancellationToken)) + return OperationResult.FailureResult(nameof(request.OptionGroupId), "Option group not found."); + + var value = new ServiceOptionValue + { + OptionGroupId = request.OptionGroupId, + NameFa = request.NameFa, + NameEn = request.NameEn, + SortOrder = request.SortOrder, + IsActive = true + }; + + await unitOfWork.CatalogRepository.AddValueAsync(value, cancellationToken); + await unitOfWork.CommitAsync(); + + await CatalogCache.InvalidateAsync(cache, cancellationToken); + + return OperationResult.SuccessResult(new OptionValueDto( + value.Id, value.NameFa, value.NameEn, value.SortOrder, value.IsActive)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Catalog/Commands/CreateServiceOptionValue/CreateServiceOptionValueCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Catalog/Commands/CreateServiceOptionValue/CreateServiceOptionValueCommand.Validator.cs new file mode 100644 index 0000000..6a22f9c --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Catalog/Commands/CreateServiceOptionValue/CreateServiceOptionValueCommand.Validator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace Baya.Application.Features.Catalog.Commands.CreateServiceOptionValue; + +public sealed class CreateServiceOptionValueCommandValidator : AbstractValidator +{ + public CreateServiceOptionValueCommandValidator() + { + RuleFor(x => x.OptionGroupId).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/Catalog/Commands/CreateServiceOptionValue/CreateServiceOptionValueCommand.cs b/server/src/Core/Baya.Application/Features/Catalog/Commands/CreateServiceOptionValue/CreateServiceOptionValueCommand.cs new file mode 100644 index 0000000..cc4ccf1 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Catalog/Commands/CreateServiceOptionValue/CreateServiceOptionValueCommand.cs @@ -0,0 +1,12 @@ +using Baya.Application.Models.Catalog; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Catalog.Commands.CreateServiceOptionValue; + +/// Admin: add a concrete choice to an option group. Both labels required. Invalidates cache. +public record CreateServiceOptionValueCommand( + long OptionGroupId, + string NameFa, + string NameEn, + int SortOrder) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Catalog/Commands/SetServiceCategoryActive/SetServiceCategoryActiveCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Catalog/Commands/SetServiceCategoryActive/SetServiceCategoryActiveCommand.Handler.cs new file mode 100644 index 0000000..a104e33 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Catalog/Commands/SetServiceCategoryActive/SetServiceCategoryActiveCommand.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.Catalog.Commands.SetServiceCategoryActive; + +internal sealed class SetServiceCategoryActiveCommandHandler(IUnitOfWork unitOfWork, ICacheService cache) + : IRequestHandler> +{ + public async ValueTask> Handle(SetServiceCategoryActiveCommand request, CancellationToken cancellationToken) + { + var category = await unitOfWork.CatalogRepository.GetCategoryAsync(request.Id, cancellationToken); + if (category is null) + return OperationResult.NotFoundResult("Category not found."); + + category.IsActive = request.IsActive; + + await unitOfWork.CommitAsync(); + await CatalogCache.InvalidateAsync(cache, cancellationToken); + + return OperationResult.SuccessResult(true); + } +} diff --git a/server/src/Core/Baya.Application/Features/Catalog/Commands/SetServiceCategoryActive/SetServiceCategoryActiveCommand.cs b/server/src/Core/Baya.Application/Features/Catalog/Commands/SetServiceCategoryActive/SetServiceCategoryActiveCommand.cs new file mode 100644 index 0000000..51f6c0c --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Catalog/Commands/SetServiceCategoryActive/SetServiceCategoryActiveCommand.cs @@ -0,0 +1,11 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Catalog.Commands.SetServiceCategoryActive; + +/// +/// Admin: toggle a category's active flag. Soft state only — never hard-delete. Deactivating hides +/// the category from public browse and from new variant creation; existing variants in it are left intact +/// (their bookings/history survive via the booking snapshot). comes from the route. +/// +public record SetServiceCategoryActiveCommand(long Id, bool IsActive) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Catalog/Commands/UpdateServiceCategory/UpdateServiceCategoryCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Catalog/Commands/UpdateServiceCategory/UpdateServiceCategoryCommand.Handler.cs new file mode 100644 index 0000000..cf7ff6d --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Catalog/Commands/UpdateServiceCategory/UpdateServiceCategoryCommand.Handler.cs @@ -0,0 +1,32 @@ +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Catalog; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Catalog.Commands.UpdateServiceCategory; + +internal sealed class UpdateServiceCategoryCommandHandler(IUnitOfWork unitOfWork, ICacheService cache) + : IRequestHandler> +{ + public async ValueTask> Handle(UpdateServiceCategoryCommand request, CancellationToken cancellationToken) + { + var category = await unitOfWork.CatalogRepository.GetCategoryAsync(request.Id, cancellationToken); + if (category is null) + return OperationResult.NotFoundResult("Category not found."); + + category.NameFa = request.NameFa; + category.NameEn = request.NameEn; + category.DescriptionFa = request.DescriptionFa; + category.DescriptionEn = request.DescriptionEn; + category.IconKey = request.IconKey; + category.SortOrder = request.SortOrder; + + await unitOfWork.CommitAsync(); + await CatalogCache.InvalidateAsync(cache, cancellationToken); + + return OperationResult.SuccessResult(new ServiceCategoryDto( + category.Id, category.NameFa, category.NameEn, category.DescriptionFa, category.DescriptionEn, + category.IconKey, category.SortOrder, category.IsActive)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Catalog/Commands/UpdateServiceCategory/UpdateServiceCategoryCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Catalog/Commands/UpdateServiceCategory/UpdateServiceCategoryCommand.Validator.cs new file mode 100644 index 0000000..bd2e1fb --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Catalog/Commands/UpdateServiceCategory/UpdateServiceCategoryCommand.Validator.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +namespace Baya.Application.Features.Catalog.Commands.UpdateServiceCategory; + +public sealed class UpdateServiceCategoryCommandValidator : AbstractValidator +{ + // Id is supplied by the route (set after model binding), so it is not validated here. + public UpdateServiceCategoryCommandValidator() + { + RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150); + RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150); + RuleFor(x => x.DescriptionFa).MaximumLength(1000); + RuleFor(x => x.DescriptionEn).MaximumLength(1000); + RuleFor(x => x.IconKey).MaximumLength(100); + } +} diff --git a/server/src/Core/Baya.Application/Features/Catalog/Commands/UpdateServiceCategory/UpdateServiceCategoryCommand.cs b/server/src/Core/Baya.Application/Features/Catalog/Commands/UpdateServiceCategory/UpdateServiceCategoryCommand.cs new file mode 100644 index 0000000..c049e09 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Catalog/Commands/UpdateServiceCategory/UpdateServiceCategoryCommand.cs @@ -0,0 +1,16 @@ +#nullable enable +using Baya.Application.Models.Catalog; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Catalog.Commands.UpdateServiceCategory; + +/// Admin: edit a category's labels/description/icon/order. comes from the route. +public record UpdateServiceCategoryCommand( + long Id, + string NameFa, + string NameEn, + string? DescriptionFa, + string? DescriptionEn, + string? IconKey, + int SortOrder) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Catalog/Commands/UpdateServiceOptionGroup/UpdateServiceOptionGroupCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Catalog/Commands/UpdateServiceOptionGroup/UpdateServiceOptionGroupCommand.Handler.cs new file mode 100644 index 0000000..e2c089d --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Catalog/Commands/UpdateServiceOptionGroup/UpdateServiceOptionGroupCommand.Handler.cs @@ -0,0 +1,34 @@ +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Catalog; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Catalog.Commands.UpdateServiceOptionGroup; + +internal sealed class UpdateServiceOptionGroupCommandHandler(IUnitOfWork unitOfWork, ICacheService cache) + : IRequestHandler> +{ + public async ValueTask> Handle(UpdateServiceOptionGroupCommand request, CancellationToken cancellationToken) + { + var group = await unitOfWork.CatalogRepository.GetGroupAsync(request.Id, cancellationToken); + if (group is null) + return OperationResult.NotFoundResult("Option group not found."); + + if (request.ServiceCategoryId is { } categoryId + && !await unitOfWork.CatalogRepository.CategoryExistsAsync(categoryId, cancellationToken)) + return OperationResult.FailureResult(nameof(request.ServiceCategoryId), "Category not found."); + + group.ServiceCategoryId = request.ServiceCategoryId; + group.NameFa = request.NameFa; + group.NameEn = request.NameEn; + group.IsRequired = request.IsRequired; + group.SortOrder = request.SortOrder; + + await unitOfWork.CommitAsync(); + await CatalogCache.InvalidateAsync(cache, cancellationToken); + + var dto = await unitOfWork.CatalogRepository.GetGroupDtoAsync(group.Id, cancellationToken); + return OperationResult.SuccessResult(dto!); + } +} diff --git a/server/src/Core/Baya.Application/Features/Catalog/Commands/UpdateServiceOptionGroup/UpdateServiceOptionGroupCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Catalog/Commands/UpdateServiceOptionGroup/UpdateServiceOptionGroupCommand.Validator.cs new file mode 100644 index 0000000..b78826a --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Catalog/Commands/UpdateServiceOptionGroup/UpdateServiceOptionGroupCommand.Validator.cs @@ -0,0 +1,14 @@ +using FluentValidation; + +namespace Baya.Application.Features.Catalog.Commands.UpdateServiceOptionGroup; + +public sealed class UpdateServiceOptionGroupCommandValidator : AbstractValidator +{ + // Id is supplied by the route (set after model binding), so it is not validated here. + public UpdateServiceOptionGroupCommandValidator() + { + RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150); + RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150); + RuleFor(x => x.ServiceCategoryId).GreaterThan(0).When(x => x.ServiceCategoryId.HasValue); + } +} diff --git a/server/src/Core/Baya.Application/Features/Catalog/Commands/UpdateServiceOptionGroup/UpdateServiceOptionGroupCommand.cs b/server/src/Core/Baya.Application/Features/Catalog/Commands/UpdateServiceOptionGroup/UpdateServiceOptionGroupCommand.cs new file mode 100644 index 0000000..23c4da9 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Catalog/Commands/UpdateServiceOptionGroup/UpdateServiceOptionGroupCommand.cs @@ -0,0 +1,16 @@ +#nullable enable +using Baya.Application.Models.Catalog; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Catalog.Commands.UpdateServiceOptionGroup; + +/// Admin: edit a dimension's category scope (null = cross-category), labels, required flag, order. +/// comes from the route. +public record UpdateServiceOptionGroupCommand( + long Id, + long? ServiceCategoryId, + string NameFa, + string NameEn, + bool IsRequired, + int SortOrder) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Catalog/Commands/UpdateServiceOptionValue/UpdateServiceOptionValueCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Catalog/Commands/UpdateServiceOptionValue/UpdateServiceOptionValueCommand.Handler.cs new file mode 100644 index 0000000..8f1d9ee --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Catalog/Commands/UpdateServiceOptionValue/UpdateServiceOptionValueCommand.Handler.cs @@ -0,0 +1,29 @@ +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Catalog; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Catalog.Commands.UpdateServiceOptionValue; + +internal sealed class UpdateServiceOptionValueCommandHandler(IUnitOfWork unitOfWork, ICacheService cache) + : IRequestHandler> +{ + public async ValueTask> Handle(UpdateServiceOptionValueCommand request, CancellationToken cancellationToken) + { + var value = await unitOfWork.CatalogRepository.GetValueAsync(request.Id, cancellationToken); + if (value is null) + return OperationResult.NotFoundResult("Option value not found."); + + value.NameFa = request.NameFa; + value.NameEn = request.NameEn; + value.SortOrder = request.SortOrder; + value.IsActive = request.IsActive; + + await unitOfWork.CommitAsync(); + await CatalogCache.InvalidateAsync(cache, cancellationToken); + + return OperationResult.SuccessResult(new OptionValueDto( + value.Id, value.NameFa, value.NameEn, value.SortOrder, value.IsActive)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Catalog/Commands/UpdateServiceOptionValue/UpdateServiceOptionValueCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Catalog/Commands/UpdateServiceOptionValue/UpdateServiceOptionValueCommand.Validator.cs new file mode 100644 index 0000000..c0371cc --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Catalog/Commands/UpdateServiceOptionValue/UpdateServiceOptionValueCommand.Validator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace Baya.Application.Features.Catalog.Commands.UpdateServiceOptionValue; + +public sealed class UpdateServiceOptionValueCommandValidator : AbstractValidator +{ + // Id is supplied by the route (set after model binding), so it is not validated here. + public UpdateServiceOptionValueCommandValidator() + { + RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150); + RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150); + } +} diff --git a/server/src/Core/Baya.Application/Features/Catalog/Commands/UpdateServiceOptionValue/UpdateServiceOptionValueCommand.cs b/server/src/Core/Baya.Application/Features/Catalog/Commands/UpdateServiceOptionValue/UpdateServiceOptionValueCommand.cs new file mode 100644 index 0000000..fe1a765 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Catalog/Commands/UpdateServiceOptionValue/UpdateServiceOptionValueCommand.cs @@ -0,0 +1,17 @@ +using Baya.Application.Models.Catalog; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Catalog.Commands.UpdateServiceOptionValue; + +/// +/// Admin: edit a value's labels/order and activate/deactivate it. Re-parenting to a different group is +/// deliberately not allowed — it would silently change the meaning of variants that already answered with +/// this value. comes from the route. +/// +public record UpdateServiceOptionValueCommand( + long Id, + string NameFa, + string NameEn, + int SortOrder, + bool IsActive) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Catalog/Queries/GetCatalogCategories/GetCatalogCategoriesQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Catalog/Queries/GetCatalogCategories/GetCatalogCategoriesQuery.Handler.cs new file mode 100644 index 0000000..2670291 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Catalog/Queries/GetCatalogCategories/GetCatalogCategoriesQuery.Handler.cs @@ -0,0 +1,32 @@ +using Baya.Application.Common; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Catalog; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Catalog.Queries.GetCatalogCategories; + +internal sealed class GetCatalogCategoriesQueryHandler(IUnitOfWork unitOfWork, ICacheService cache) + : IRequestHandler>> +{ + public async ValueTask>> Handle(GetCatalogCategoriesQuery request, CancellationToken cancellationToken) + { + var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize); + + var version = await CatalogCache.VersionAsync(cache, cancellationToken); + + // The active-category set is small, near-static reference data — cache the whole ordered list once + // per generation token and page it in memory, so a mutation's cache bump refreshes every page. + var all = await cache.GetOrCreateAsync( + CatalogCache.CategoriesKey(version), + async ct => await unitOfWork.CatalogRepository.ListActiveCategoriesAsync(ct), + CatalogCache.Ttl, + cancellationToken); + + var items = all.Skip((page - 1) * pageSize).Take(pageSize).ToList(); + + return OperationResult>.SuccessResult( + new PagedResult(items, all.Count, page, pageSize)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Catalog/Queries/GetCatalogCategories/GetCatalogCategoriesQuery.cs b/server/src/Core/Baya.Application/Features/Catalog/Queries/GetCatalogCategories/GetCatalogCategoriesQuery.cs new file mode 100644 index 0000000..3dccacb --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Catalog/Queries/GetCatalogCategories/GetCatalogCategoriesQuery.cs @@ -0,0 +1,9 @@ +using Baya.Application.Models.Catalog; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Catalog.Queries.GetCatalogCategories; + +/// Public: active categories ordered by sort order, paginated. Cached reference data. +public record GetCatalogCategoriesQuery(int Page = 1, int PageSize = 50) + : IRequest>>; diff --git a/server/src/Core/Baya.Application/Features/Catalog/Queries/GetCategoryOptionGroups/GetCategoryOptionGroupsQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Catalog/Queries/GetCategoryOptionGroups/GetCategoryOptionGroupsQuery.Handler.cs new file mode 100644 index 0000000..c53ffab --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Catalog/Queries/GetCategoryOptionGroups/GetCategoryOptionGroupsQuery.Handler.cs @@ -0,0 +1,24 @@ +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Catalog; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Catalog.Queries.GetCategoryOptionGroups; + +internal sealed class GetCategoryOptionGroupsQueryHandler(IUnitOfWork unitOfWork, ICacheService cache) + : IRequestHandler>> +{ + public async ValueTask>> Handle(GetCategoryOptionGroupsQuery request, CancellationToken cancellationToken) + { + var version = await CatalogCache.VersionAsync(cache, cancellationToken); + + var groups = await cache.GetOrCreateAsync( + CatalogCache.OptionGroupsKey(version, request.CategoryId), + async ct => await unitOfWork.CatalogRepository.GetApplicableGroupsAsync(request.CategoryId, ct), + CatalogCache.Ttl, + cancellationToken); + + return OperationResult>.SuccessResult(groups); + } +} diff --git a/server/src/Core/Baya.Application/Features/Catalog/Queries/GetCategoryOptionGroups/GetCategoryOptionGroupsQuery.cs b/server/src/Core/Baya.Application/Features/Catalog/Queries/GetCategoryOptionGroups/GetCategoryOptionGroupsQuery.cs new file mode 100644 index 0000000..9d1da5c --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Catalog/Queries/GetCategoryOptionGroups/GetCategoryOptionGroupsQuery.cs @@ -0,0 +1,13 @@ +using Baya.Application.Models.Catalog; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Catalog.Queries.GetCategoryOptionGroups; + +/// +/// Public: a category's applicable option groups — its own groups plus every cross-category (NULL) +/// group — each with its active values and is_required, ordered by sort order. The skeleton the +/// nurse builder fills in and the customer browses. Cached reference data. +/// +public record GetCategoryOptionGroupsQuery(long CategoryId) + : IRequest>>; diff --git a/server/src/Core/Baya.Application/Features/Variants/Commands/CreateVariant/CreateVariantCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Variants/Commands/CreateVariant/CreateVariantCommand.Handler.cs new file mode 100644 index 0000000..e8d5814 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Variants/Commands/CreateVariant/CreateVariantCommand.Handler.cs @@ -0,0 +1,132 @@ +#nullable enable +using System.Globalization; +using Baya.Application.Common; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Catalog; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.Catalog; +using Baya.Domain.Entities.User; +using Mediator; + +namespace Baya.Application.Features.Variants.Commands.CreateVariant; + +internal sealed class CreateVariantCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async ValueTask> Handle(CreateVariantCommand 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 create a variant."); + + 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."); + + // Catalog must be seeded and the category active — a variant can't rest on a missing/inactive category. + var category = await unitOfWork.CatalogRepository.GetActiveCategoryAsync(request.ServiceCategoryId, cancellationToken); + if (category is null) + return OperationResult.FailureResult(nameof(request.ServiceCategoryId), "Category not found or inactive."); + + // Applicable = the category's own active groups PLUS every cross-category (NULL) active group. + var applicableGroups = await unitOfWork.CatalogRepository.GetApplicableGroupsAsync(request.ServiceCategoryId, cancellationToken); + var groupById = applicableGroups.ToDictionary(g => g.Id); + + // Every submitted (group, value) must apply to this category and the value must belong to its group. + foreach (var selection in request.Options) + { + if (!groupById.TryGetValue(selection.OptionGroupId, out var group)) + return OperationResult.FailureResult( + nameof(request.Options), $"Option group {selection.OptionGroupId} does not apply to this category."); + + if (group.Values.All(v => v.Id != selection.OptionValueId)) + return OperationResult.FailureResult( + nameof(request.Options), $"Option value {selection.OptionValueId} is not a valid choice for '{group.NameFa}'."); + } + + // One value per dimension: no group answered twice. + var answeredGroupIds = request.Options.Select(o => o.OptionGroupId).ToList(); + if (answeredGroupIds.Count != answeredGroupIds.Distinct().Count()) + return OperationResult.FailureResult(nameof(request.Options), "A dimension was answered more than once."); + + // Every required dimension (incl. cross-category ones) must be answered. + var answered = answeredGroupIds.ToHashSet(); + var missingRequired = applicableGroups.Where(g => g.IsRequired && !answered.Contains(g.Id)).ToList(); + if (missingRequired.Count > 0) + return OperationResult.FailureResult( + nameof(request.Options), + $"Required dimension(s) not answered: {string.Join(", ", missingRequired.Select(g => g.NameFa))}."); + + var resolvedOptions = ResolveOptions(request.Options, groupById); + var optionSetHash = OptionSetHash.Compute(request.Options.Select(o => (o.OptionGroupId, o.OptionValueId))); + + // Duplicate-listing guard: friendly pre-check ahead of the filtered unique-index DB backstop. + if (await unitOfWork.NurseServiceVariantRepository.DuplicateHashExistsAsync(nid, category.Id, optionSetHash, null, cancellationToken)) + return OperationResult.ConflictResult("You already offer this exact configuration in this category."); + + var price = long.Parse(request.Price, NumberStyles.None, CultureInfo.InvariantCulture); + var displayName = string.IsNullOrWhiteSpace(request.DisplayName) + ? BuildDisplayName(category.NameFa, resolvedOptions) + : request.DisplayName.Trim(); + + var variant = new NurseServiceVariant + { + NurseId = nid, + ServiceCategoryId = category.Id, + Price = price, + PriceUnit = request.PriceUnit, + SessionCount = request.SessionCount, + DisplayName = displayName, + OptionSetHash = optionSetHash, + IsActive = true, + Options = request.Options + .Select(o => new NurseServiceVariantOption + { + OptionGroupId = o.OptionGroupId, + OptionValueId = o.OptionValueId + }) + .ToList() + }; + + // DEFERRED (b7): this is the write that later fans a variant out into nurse_search_index. Keep it the + // single trigger point — do not build the index here. + await unitOfWork.NurseServiceVariantRepository.AddAsync(variant, cancellationToken); + await unitOfWork.CommitAsync(); + + return OperationResult.SuccessResult(new VariantDto( + variant.Id, + category.Id, + category.NameFa, + category.NameEn, + variant.Price.ToString(CultureInfo.InvariantCulture), + variant.PriceUnit, + variant.SessionCount, + variant.DisplayName, + variant.IsActive, + resolvedOptions)); + } + + private static IReadOnlyList ResolveOptions( + IReadOnlyList selections, + IReadOnlyDictionary groupById) + => selections + .Select(s => + { + var group = groupById[s.OptionGroupId]; + var value = group.Values.First(v => v.Id == s.OptionValueId); + return (group, value); + }) + .OrderBy(x => x.group.SortOrder) + .ThenBy(x => x.group.Id) + .Select(x => new VariantOptionDto( + x.group.Id, x.group.NameFa, x.group.NameEn, x.value.Id, x.value.NameFa, x.value.NameEn)) + .ToList(); + + private static string BuildDisplayName(string categoryNameFa, IReadOnlyList options) + => options.Count == 0 + ? categoryNameFa + : $"{categoryNameFa} · {string.Join(" · ", options.Select(o => o.ValueNameFa))}"; +} diff --git a/server/src/Core/Baya.Application/Features/Variants/Commands/CreateVariant/CreateVariantCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Variants/Commands/CreateVariant/CreateVariantCommand.Validator.cs new file mode 100644 index 0000000..22ef5ae --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Variants/Commands/CreateVariant/CreateVariantCommand.Validator.cs @@ -0,0 +1,39 @@ +using System.Globalization; +using Baya.Domain.Entities.Catalog; +using FluentValidation; + +namespace Baya.Application.Features.Variants.Commands.CreateVariant; + +public sealed class CreateVariantCommandValidator : AbstractValidator +{ + public CreateVariantCommandValidator() + { + RuleFor(x => x.ServiceCategoryId).GreaterThan(0); + + // Money is a string of IRR-Rial digits — positive integer, no sign/decimal/whitespace, no overflow. + RuleFor(x => x.Price) + .NotEmpty() + .Must(BePositiveIrrAmount) + .WithMessage("Price must be a positive integer number of IRR Rials (digits only)."); + + RuleFor(x => x.PriceUnit) + .Must(PriceUnits.IsValid) + .WithMessage("price_unit must be one of: per_hour, per_session, per_half_day, per_day, per_24h."); + + RuleFor(x => x.SessionCount).GreaterThan(0).When(x => x.SessionCount.HasValue); + + RuleFor(x => x.DisplayName).MaximumLength(300); + + // The required-group / one-value-per-dimension / value-belongs-to-group rules need the catalog, so + // they live in the handler (clean OperationResult). Here we only enforce the shape. + RuleFor(x => x.Options).NotNull(); + RuleForEach(x => x.Options).ChildRules(o => + { + o.RuleFor(s => s.OptionGroupId).GreaterThan(0); + o.RuleFor(s => s.OptionValueId).GreaterThan(0); + }); + } + + private static bool BePositiveIrrAmount(string value) + => long.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var amount) && amount > 0; +} diff --git a/server/src/Core/Baya.Application/Features/Variants/Commands/CreateVariant/CreateVariantCommand.cs b/server/src/Core/Baya.Application/Features/Variants/Commands/CreateVariant/CreateVariantCommand.cs new file mode 100644 index 0000000..fffe50f --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Variants/Commands/CreateVariant/CreateVariantCommand.cs @@ -0,0 +1,20 @@ +#nullable enable +using Baya.Application.Models.Catalog; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Variants.Commands.CreateVariant; + +/// +/// The signed-in nurse builds a priced offering: a category, one chosen value per answered dimension, a +/// price (IRR-Rial digit string) + price unit + optional session count, and an optional display-name +/// override (auto-generated from the option labels when omitted). The nurse is derived from the caller, +/// never the body. A duplicate identical listing returns 409; a missing required dimension returns 400. +/// +public record CreateVariantCommand( + long ServiceCategoryId, + IReadOnlyList Options, + string Price, + string PriceUnit, + int? SessionCount, + string? DisplayName) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Variants/Commands/SetVariantActive/SetVariantActiveCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Variants/Commands/SetVariantActive/SetVariantActiveCommand.Handler.cs new file mode 100644 index 0000000..7f09fcf --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Variants/Commands/SetVariantActive/SetVariantActiveCommand.Handler.cs @@ -0,0 +1,36 @@ +#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.Variants.Commands.SetVariantActive; + +internal sealed class SetVariantActiveCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async ValueTask> Handle(SetVariantActiveCommand 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 variants."); + + var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (nurseId is not { } nid) + return OperationResult.NotFoundResult("Variant not found."); + + var variant = await unitOfWork.NurseServiceVariantRepository.GetOwnedAsync(request.Id, nid, cancellationToken); + if (variant is null) + return OperationResult.NotFoundResult("Variant not found."); + + variant.IsActive = request.IsActive; + + // DEFERRED (b7): toggling active is the trigger point for the search-index add/remove. + await unitOfWork.CommitAsync(); + + return OperationResult.SuccessResult(true); + } +} diff --git a/server/src/Core/Baya.Application/Features/Variants/Commands/SetVariantActive/SetVariantActiveCommand.cs b/server/src/Core/Baya.Application/Features/Variants/Commands/SetVariantActive/SetVariantActiveCommand.cs new file mode 100644 index 0000000..c49cb52 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Variants/Commands/SetVariantActive/SetVariantActiveCommand.cs @@ -0,0 +1,11 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Variants.Commands.SetVariantActive; + +/// +/// The owning nurse activates/deactivates a variant. Deactivate, never hard-delete. A deactivated +/// variant cannot be booked and (via b7) drops out of the search index; its past bookings/snapshots are +/// untouched. comes from the route. +/// +public record SetVariantActiveCommand(long Id, bool IsActive) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Variants/Commands/UpdateVariant/UpdateVariantCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Variants/Commands/UpdateVariant/UpdateVariantCommand.Handler.cs new file mode 100644 index 0000000..a94d3fb --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Variants/Commands/UpdateVariant/UpdateVariantCommand.Handler.cs @@ -0,0 +1,46 @@ +#nullable enable +using System.Globalization; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Catalog; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.User; +using Mediator; + +namespace Baya.Application.Features.Variants.Commands.UpdateVariant; + +internal sealed class UpdateVariantCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async ValueTask> Handle(UpdateVariantCommand 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 edit a variant."); + + var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (nurseId is not { } nid) + return OperationResult.NotFoundResult("Variant not found."); + + // Tenancy: a non-owned/absent id resolves to null → not-found (existence is not leaked). + var variant = await unitOfWork.NurseServiceVariantRepository.GetOwnedAsync(request.Id, nid, cancellationToken); + if (variant is null) + return OperationResult.NotFoundResult("Variant not found."); + + variant.Price = long.Parse(request.Price, NumberStyles.None, CultureInfo.InvariantCulture); + variant.PriceUnit = request.PriceUnit; + variant.SessionCount = request.SessionCount; + if (!string.IsNullOrWhiteSpace(request.DisplayName)) + variant.DisplayName = request.DisplayName.Trim(); + + await unitOfWork.CommitAsync(); + + // Re-project with resolved labels for the response (the option-set is unchanged). + var dto = await unitOfWork.NurseServiceVariantRepository.GetOwnedProjectedAsync(request.Id, nid, cancellationToken); + return dto is null + ? OperationResult.NotFoundResult("Variant not found.") + : OperationResult.SuccessResult(dto); + } +} diff --git a/server/src/Core/Baya.Application/Features/Variants/Commands/UpdateVariant/UpdateVariantCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Variants/Commands/UpdateVariant/UpdateVariantCommand.Validator.cs new file mode 100644 index 0000000..f72af49 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Variants/Commands/UpdateVariant/UpdateVariantCommand.Validator.cs @@ -0,0 +1,28 @@ +using System.Globalization; +using Baya.Domain.Entities.Catalog; +using FluentValidation; + +namespace Baya.Application.Features.Variants.Commands.UpdateVariant; + +public sealed class UpdateVariantCommandValidator : AbstractValidator +{ + // Id is supplied by the route (set after model binding), so it is not validated here. + public UpdateVariantCommandValidator() + { + RuleFor(x => x.Price) + .NotEmpty() + .Must(BePositiveIrrAmount) + .WithMessage("Price must be a positive integer number of IRR Rials (digits only)."); + + RuleFor(x => x.PriceUnit) + .Must(PriceUnits.IsValid) + .WithMessage("price_unit must be one of: per_hour, per_session, per_half_day, per_day, per_24h."); + + RuleFor(x => x.SessionCount).GreaterThan(0).When(x => x.SessionCount.HasValue); + + RuleFor(x => x.DisplayName).MaximumLength(300); + } + + private static bool BePositiveIrrAmount(string value) + => long.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var amount) && amount > 0; +} diff --git a/server/src/Core/Baya.Application/Features/Variants/Commands/UpdateVariant/UpdateVariantCommand.cs b/server/src/Core/Baya.Application/Features/Variants/Commands/UpdateVariant/UpdateVariantCommand.cs new file mode 100644 index 0000000..42f4adb --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Variants/Commands/UpdateVariant/UpdateVariantCommand.cs @@ -0,0 +1,19 @@ +#nullable enable +using Baya.Application.Models.Catalog; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Variants.Commands.UpdateVariant; + +/// +/// The owning nurse edits a variant's price, price unit, session count, and display name. The option-set +/// is immutable here — changing dimensions is modelled as create-new + deactivate-old so historical +/// meaning stays stable, which also means an edit can never collide with the duplicate-listing guard. +/// A blank leaves the current one unchanged. comes from the route. +/// +public record UpdateVariantCommand( + long Id, + string Price, + string PriceUnit, + int? SessionCount, + string? DisplayName) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Variants/Queries/GetVariant/GetVariantQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Variants/Queries/GetVariant/GetVariantQuery.Handler.cs new file mode 100644 index 0000000..af5fb2a --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Variants/Queries/GetVariant/GetVariantQuery.Handler.cs @@ -0,0 +1,43 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Catalog; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.User; +using Mediator; + +namespace Baya.Application.Features.Variants.Queries.GetVariant; + +internal sealed class GetVariantQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async ValueTask> Handle(GetVariantQuery request, CancellationToken cancellationToken) + { + var roles = currentUser.Roles; + + // Admin: the full view of any variant, any state. + if (roles?.Contains(RoleNames.Admin) == true) + return Resolve(await unitOfWork.NurseServiceVariantRepository.GetProjectedAsync(request.Id, cancellationToken)); + + // Owning nurse: the full view of their own variant, any state. + if (currentUser.UserId is { } userId && roles?.Contains(RoleNames.Nurse) == true) + { + var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (nurseId is { } nid) + { + var owned = await unitOfWork.NurseServiceVariantRepository.GetOwnedProjectedAsync(request.Id, nid, cancellationToken); + if (owned is not null) + return OperationResult.SuccessResult(owned); + } + // Not their variant → fall through to the public (active-only) projection. + } + + // Everyone else (incl. anonymous): the public-safe projection — active variants only. + return Resolve(await unitOfWork.NurseServiceVariantRepository.GetPublicProjectedAsync(request.Id, cancellationToken)); + } + + private static OperationResult Resolve(VariantDto? dto) + => dto is null + ? OperationResult.NotFoundResult("Variant not found.") + : OperationResult.SuccessResult(dto); +} diff --git a/server/src/Core/Baya.Application/Features/Variants/Queries/GetVariant/GetVariantQuery.cs b/server/src/Core/Baya.Application/Features/Variants/Queries/GetVariant/GetVariantQuery.cs new file mode 100644 index 0000000..c30d932 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Variants/Queries/GetVariant/GetVariantQuery.cs @@ -0,0 +1,12 @@ +using Baya.Application.Models.Catalog; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Variants.Queries.GetVariant; + +/// +/// A single variant with its full resolved option-set. The owning nurse and an admin see it in any state; +/// any other caller (the public nurse-profile view) sees only an active variant. Absent/inaccessible +/// resolves to not-found. +/// +public record GetVariantQuery(long Id) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Variants/Queries/ListMyVariants/ListMyVariantsQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Variants/Queries/ListMyVariants/ListMyVariantsQuery.Handler.cs new file mode 100644 index 0000000..a7a5ce8 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Variants/Queries/ListMyVariants/ListMyVariantsQuery.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.Catalog; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.User; +using Mediator; + +namespace Baya.Application.Features.Variants.Queries.ListMyVariants; + +internal sealed class ListMyVariantsQueryHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork) + : IRequestHandler>> +{ + public async ValueTask>> Handle(ListMyVariantsQuery 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 their variants."); + + 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.NurseServiceVariantRepository.ListMineAsync(nid, page, pageSize, cancellationToken); + return OperationResult>.SuccessResult(result); + } +} diff --git a/server/src/Core/Baya.Application/Features/Variants/Queries/ListMyVariants/ListMyVariantsQuery.cs b/server/src/Core/Baya.Application/Features/Variants/Queries/ListMyVariants/ListMyVariantsQuery.cs new file mode 100644 index 0000000..8280baa --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Variants/Queries/ListMyVariants/ListMyVariantsQuery.cs @@ -0,0 +1,9 @@ +using Baya.Application.Models.Catalog; +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Variants.Queries.ListMyVariants; + +/// The signed-in nurse's own offerings — active and inactive — paginated, active-first. +public record ListMyVariantsQuery(int Page = 1, int PageSize = 50) + : IRequest>>; diff --git a/server/src/Core/Baya.Application/Models/Catalog/OptionGroupDto.cs b/server/src/Core/Baya.Application/Models/Catalog/OptionGroupDto.cs new file mode 100644 index 0000000..d6cf7a2 --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Catalog/OptionGroupDto.cs @@ -0,0 +1,17 @@ +#nullable enable +namespace Baya.Application.Models.Catalog; + +/// +/// A pricing dimension applicable to a category, with its active values. ServiceCategoryId == null +/// marks a cross-category group (applies to every category). This is the skeleton the nurse builder fills +/// in and the customer browses. +/// +public record OptionGroupDto( + long Id, + long? ServiceCategoryId, + string NameFa, + string NameEn, + bool IsRequired, + int SortOrder, + bool IsActive, + IReadOnlyList Values); diff --git a/server/src/Core/Baya.Application/Models/Catalog/OptionValueDto.cs b/server/src/Core/Baya.Application/Models/Catalog/OptionValueDto.cs new file mode 100644 index 0000000..a33c242 --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Catalog/OptionValueDto.cs @@ -0,0 +1,9 @@ +namespace Baya.Application.Models.Catalog; + +/// A concrete choice within an option group (e.g. شبانه‌روزی / live-in). +public record OptionValueDto( + long Id, + string NameFa, + string NameEn, + int SortOrder, + bool IsActive); diff --git a/server/src/Core/Baya.Application/Models/Catalog/ServiceCategoryDto.cs b/server/src/Core/Baya.Application/Models/Catalog/ServiceCategoryDto.cs new file mode 100644 index 0000000..cb7e63b --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Catalog/ServiceCategoryDto.cs @@ -0,0 +1,13 @@ +#nullable enable +namespace Baya.Application.Models.Catalog; + +/// An admin catalog category. NameFa is primary; the client picks the label by locale. +public record ServiceCategoryDto( + long Id, + string NameFa, + string NameEn, + string? DescriptionFa, + string? DescriptionEn, + string? IconKey, + int SortOrder, + bool IsActive); diff --git a/server/src/Core/Baya.Application/Models/Catalog/VariantDto.cs b/server/src/Core/Baya.Application/Models/Catalog/VariantDto.cs new file mode 100644 index 0000000..d6ded90 --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Catalog/VariantDto.cs @@ -0,0 +1,18 @@ +namespace Baya.Application.Models.Catalog; + +/// +/// A nurse's priced offering with its resolved option-set. Price crosses the wire as a string of +/// IRR-Rial digits (integer money, no floats). The engagement total is Price + PriceUnit +/// + SessionCount — a downstream consumer derives it, never from price alone. +/// +public record VariantDto( + long Id, + long ServiceCategoryId, + string CategoryNameFa, + string CategoryNameEn, + string Price, + string PriceUnit, + int? SessionCount, + string DisplayName, + bool IsActive, + IReadOnlyList Options); diff --git a/server/src/Core/Baya.Application/Models/Catalog/VariantOptionDto.cs b/server/src/Core/Baya.Application/Models/Catalog/VariantOptionDto.cs new file mode 100644 index 0000000..bab429a --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Catalog/VariantOptionDto.cs @@ -0,0 +1,10 @@ +namespace Baya.Application.Models.Catalog; + +/// One answered dimension of a variant, with both the group and value labels resolved. +public record VariantOptionDto( + long OptionGroupId, + string GroupNameFa, + string GroupNameEn, + long OptionValueId, + string ValueNameFa, + string ValueNameEn); diff --git a/server/src/Core/Baya.Application/Models/Catalog/VariantOptionSelection.cs b/server/src/Core/Baya.Application/Models/Catalog/VariantOptionSelection.cs new file mode 100644 index 0000000..8caf5ad --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Catalog/VariantOptionSelection.cs @@ -0,0 +1,5 @@ +namespace Baya.Application.Models.Catalog; + +/// One dimension answered when building a variant: the chosen value for a group. The nurse sends +/// one of these per group they answer; the handler validates them against the category's applicable groups. +public record VariantOptionSelection(long OptionGroupId, long OptionValueId); diff --git a/server/src/Core/Baya.Application/Models/Catalog/VariantSnapshot.cs b/server/src/Core/Baya.Application/Models/Catalog/VariantSnapshot.cs new file mode 100644 index 0000000..7f9be5c --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Catalog/VariantSnapshot.cs @@ -0,0 +1,17 @@ +namespace Baya.Application.Models.Catalog; + +/// +/// The immutable input the variant-snapshot serializer freezes onto a booking (b8) — a variant + its +/// resolved options exactly as they are at serialize time. Price is the raw IRR-Rial integer; +/// the serializer emits it as a string of digits per the money convention. +/// +public record VariantSnapshot( + long VariantId, + long ServiceCategoryId, + string CategoryNameFa, + string CategoryNameEn, + long Price, + string PriceUnit, + int? SessionCount, + string DisplayName, + IReadOnlyList Options); diff --git a/server/src/Core/Baya.Application/ServiceConfiguration/ServiceCollectionExtension.cs b/server/src/Core/Baya.Application/ServiceConfiguration/ServiceCollectionExtension.cs index de5040c..d832e33 100644 --- a/server/src/Core/Baya.Application/ServiceConfiguration/ServiceCollectionExtension.cs +++ b/server/src/Core/Baya.Application/ServiceConfiguration/ServiceCollectionExtension.cs @@ -1,4 +1,5 @@ using Baya.Application.Common; +using Baya.Application.Contracts.Common; using FluentValidation; using Mediator; using Microsoft.Extensions.DependencyInjection; @@ -21,6 +22,9 @@ public static class ServiceCollectionExtension services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidateCommandBehavior<,>)); + // Pure, stateless serializer that b8 consumes to freeze a variant onto a booking. + services.AddSingleton(); + RegisterCommandValidators(services); return services; diff --git a/server/src/Core/Baya.Domain/Entities/Catalog/NurseServiceVariant.cs b/server/src/Core/Baya.Domain/Entities/Catalog/NurseServiceVariant.cs new file mode 100644 index 0000000..302ef9a --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Catalog/NurseServiceVariant.cs @@ -0,0 +1,50 @@ +using Baya.Domain.Common; +using Baya.Domain.Entities.Identity; + +namespace Baya.Domain.Entities.Catalog; + +/// +/// The atomic bookable unit of the marketplace: a nurse offering a category with a chosen option +/// combination at their own price and price unit. Search (b7), booking (b8), and every money calculation +/// operate on a variant — never on "a nurse". A nurse with no active variant is not bookable. +/// +/// is IRR Rials as an integer — no float, ever. The engagement total is +/// combined with and ; a downstream +/// consumer (booking) derives it from all three, never from price alone. +/// +/// +/// is a deterministic hash of the sorted answered +/// (option_group_id, option_value_id) pairs. It backs the filtered +/// UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULL that makes the +/// duplicate-listing guard race-safe (a multi-row option-set can't be a plain composite unique index). +/// +/// +public class NurseServiceVariant : BaseEntity +{ + public long NurseId { get; set; } + public NurseProfile Nurse { get; set; } + + public long ServiceCategoryId { get; set; } + public ServiceCategory ServiceCategory { get; set; } + + /// IRR Rials, integer — never a float/decimal-with-fraction. There is no Toman in the DB. + public long Price { get; set; } + + /// Closed code set — see . The only code enum in the catalog area. + public string PriceUnit { get; set; } + + /// Number of sessions/units the engagement spans; relevant for per_session and packages. + public int? SessionCount { get; set; } + + /// Auto-generated from the option labels at create time, but nurse-editable. + public string DisplayName { get; set; } + + /// Deterministic hash of the sorted answered option-set — the duplicate-listing DB backstop key. + public string OptionSetHash { get; set; } + + public bool IsActive { get; set; } + + public DateTimeOffset? DeletedAt { get; set; } + + public ICollection Options { get; set; } +} diff --git a/server/src/Core/Baya.Domain/Entities/Catalog/NurseServiceVariantOption.cs b/server/src/Core/Baya.Domain/Entities/Catalog/NurseServiceVariantOption.cs new file mode 100644 index 0000000..2169285 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Catalog/NurseServiceVariantOption.cs @@ -0,0 +1,20 @@ +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Catalog; + +/// +/// One answered dimension of a variant: the option value it chose for a given group. One row per +/// dimension makes the variant's meaning explicit and queryable. UNIQUE(variant_id, option_group_id) +/// enforces one value per dimension per variant — a variant can never answer the same group twice. +/// +public class NurseServiceVariantOption : BaseEntity +{ + public long VariantId { get; set; } + public NurseServiceVariant Variant { get; set; } + + public long OptionGroupId { get; set; } + public ServiceOptionGroup OptionGroup { get; set; } + + public long OptionValueId { get; set; } + public ServiceOptionValue OptionValue { get; set; } +} diff --git a/server/src/Core/Baya.Domain/Entities/Catalog/PriceUnits.cs b/server/src/Core/Baya.Domain/Entities/Catalog/PriceUnits.cs new file mode 100644 index 0000000..36fdcc6 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Catalog/PriceUnits.cs @@ -0,0 +1,21 @@ +namespace Baya.Domain.Entities.Catalog; + +/// +/// The five price units a nurse can price a variant in — the only closed code enum in the catalog +/// area (categories, groups, and values are data, never code constants). per_24h (شبانه‌روزی / +/// live-in) and per_day are first-class, not edge cases — Iranian home-nursing sells exactly these +/// shapes. Crosses the wire as the stable string code. +/// +public static class PriceUnits +{ + public const string PerHour = "per_hour"; + public const string PerSession = "per_session"; + public const string PerHalfDay = "per_half_day"; + public const string PerDay = "per_day"; + public const string Per24H = "per_24h"; + + public static readonly IReadOnlySet All = + new HashSet { PerHour, PerSession, PerHalfDay, PerDay, Per24H }; + + public static bool IsValid(string value) => value is not null && All.Contains(value); +} diff --git a/server/src/Core/Baya.Domain/Entities/Catalog/ServiceCategory.cs b/server/src/Core/Baya.Domain/Entities/Catalog/ServiceCategory.cs new file mode 100644 index 0000000..c011adb --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Catalog/ServiceCategory.cs @@ -0,0 +1,30 @@ +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Catalog; + +/// +/// An admin-managed top-level care type (Elderly, Post-Surgery, Infant, Chronic, Companionship) — the +/// primary search dimension and the first thing a nurse picks when building a variant. Categories are +/// data, not code: an admin adds one as a row, never a migration. Deactivate (never delete) so the +/// bookings/variants already resting on a category survive — their history lives in the booking snapshot. +/// Every row carries the NameFa (primary) + NameEn pair; the client picks by locale. +/// +public class ServiceCategory : BaseEntity +{ + public string NameFa { get; set; } + public string NameEn { get; set; } + + public string DescriptionFa { get; set; } + public string DescriptionEn { get; set; } + + /// UI glyph key the client maps to an icon; not a business value. + public string IconKey { get; set; } + + public int SortOrder { get; set; } + public bool IsActive { get; set; } + + public DateTimeOffset? DeletedAt { get; set; } + + public ICollection OptionGroups { get; set; } + public ICollection Variants { get; set; } +} diff --git a/server/src/Core/Baya.Domain/Entities/Catalog/ServiceOptionGroup.cs b/server/src/Core/Baya.Domain/Entities/Catalog/ServiceOptionGroup.cs new file mode 100644 index 0000000..c0269a1 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Catalog/ServiceOptionGroup.cs @@ -0,0 +1,33 @@ +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Catalog; + +/// +/// An admin-managed configurable pricing dimension (e.g. نوع شیفت / shift type, تعداد بیمار / +/// patient count). This is the EAV skeleton that lets a new dimension ship as rows, not a schema change. +/// +/// == null is a meaningful "cross-category" group: the +/// dimension applies to every category (e.g. shift type applies everywhere), not missing data. +/// The applicable set for a category is therefore its own groups plus every NULL-category group — +/// the required-group check and the duplicate guard must both honour that. +/// +/// +public class ServiceOptionGroup : BaseEntity +{ + /// NULL = cross-category (applies to every category). A real coverage choice, not unset. + public long? ServiceCategoryId { get; set; } + public ServiceCategory ServiceCategory { get; set; } + + public string NameFa { get; set; } + public string NameEn { get; set; } + + /// Whether a variant in an applicable category must answer this dimension exactly once. + public bool IsRequired { get; set; } + + public int SortOrder { get; set; } + public bool IsActive { get; set; } + + public DateTimeOffset? DeletedAt { get; set; } + + public ICollection Values { get; set; } +} diff --git a/server/src/Core/Baya.Domain/Entities/Catalog/ServiceOptionValue.cs b/server/src/Core/Baya.Domain/Entities/Catalog/ServiceOptionValue.cs new file mode 100644 index 0000000..ba9273b --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Catalog/ServiceOptionValue.cs @@ -0,0 +1,22 @@ +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Catalog; + +/// +/// A concrete choice inside a (e.g. شبانه‌روزی / live-in, ۲ نفر / two +/// patients). A variant answers a dimension by referencing exactly one value from that dimension's group. +/// Carries the NameFa (primary) + NameEn pair like every catalog row. +/// +public class ServiceOptionValue : BaseEntity +{ + public long OptionGroupId { get; set; } + public ServiceOptionGroup OptionGroup { 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/Infrastructure/Baya.Infrastructure.Persistence/Configuration/CatalogConfig/CatalogSeed.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/CatalogConfig/CatalogSeed.cs new file mode 100644 index 0000000..80db867 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/CatalogConfig/CatalogSeed.cs @@ -0,0 +1,36 @@ +namespace Baya.Infrastructure.Persistence.Configuration.CatalogConfig; + +/// +/// The five MVP service categories, seeded via HasData so they land with the migration on a fresh +/// DB (the b1 seeding path) — a nurse can build a variant immediately. Ids are fixed and deterministic +/// (1…5, sort_order = id) so re-running is idempotent and the model snapshot stays stable. Option +/// groups/values are not seeded: those are admin-authored data per category (EAV is load-bearing). +/// +internal static class CatalogSeed +{ + // (id, name_fa, name_en). Companionship ships only as a seeded category (data), not a pricing path. + private static readonly (long Id, string NameFa, string NameEn)[] CategoryRows = + [ + (1, "مراقبت از سالمند", "Elderly Care"), + (2, "مراقبت پس از جراحی", "Post-Surgery Recovery"), + (3, "مراقبت از نوزاد", "Infant Care"), + (4, "مدیریت بیماری مزمن", "Chronic Illness Management"), + (5, "همراهی و مراقبت روزمره", "Companionship"), + ]; + + public static object[] Categories() + { + var ts = SeedConstants.Timestamp; + return CategoryRows + .Select(c => (object)new + { + c.Id, + c.NameFa, + c.NameEn, + SortOrder = (int)c.Id, + IsActive = true, + CreatedAt = ts + }) + .ToArray(); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/CatalogConfig/NurseServiceVariantConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/CatalogConfig/NurseServiceVariantConfig.cs new file mode 100644 index 0000000..86c4062 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/CatalogConfig/NurseServiceVariantConfig.cs @@ -0,0 +1,45 @@ +using Baya.Domain.Entities.Catalog; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.CatalogConfig; + +internal sealed class NurseServiceVariantConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("NurseServiceVariants", "catalog"); + + // Price is IRR Rials as BIGINT (long → bigint). There is no float/decimal money path, ever. + builder.Property(v => v.PriceUnit).HasMaxLength(20).IsRequired(); + builder.Property(v => v.DisplayName).HasMaxLength(300).IsRequired(); + builder.Property(v => v.OptionSetHash).HasMaxLength(64).IsRequired(); + builder.Property(v => v.IsActive).HasDefaultValue(true); + + // The nurse's offerings list + the b7 index projection read on (nurse_id, is_active). + builder.HasIndex(v => new { v.NurseId, v.IsActive }); + // Leading column is nurse_id on the unique index, so a standalone category index is still useful + // for "all variants in a category" (b7 category browse). + builder.HasIndex(v => v.ServiceCategoryId); + + // Duplicate-listing DB backstop: a multi-row option-set can't be a plain composite unique, so it is + // reduced to a deterministic option_set_hash and made race-safe here. Filtered to exclude + // soft-deleted rows so a deactivated+deleted listing can be re-created. + builder.HasIndex(v => new { v.NurseId, v.ServiceCategoryId, v.OptionSetHash }) + .IsUnique() + .HasFilter("[DeletedAt] IS NULL") + .HasDatabaseName("UX_NurseServiceVariants_Nurse_Category_OptionSet"); + + builder.HasOne(v => v.Nurse) + .WithMany() + .HasForeignKey(v => v.NurseId) + .IsRequired(); + + builder.HasOne(v => v.ServiceCategory) + .WithMany(c => c.Variants) + .HasForeignKey(v => v.ServiceCategoryId) + .IsRequired(); + + builder.HasQueryFilter(v => v.DeletedAt == null); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/CatalogConfig/NurseServiceVariantOptionConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/CatalogConfig/NurseServiceVariantOptionConfig.cs new file mode 100644 index 0000000..4669469 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/CatalogConfig/NurseServiceVariantOptionConfig.cs @@ -0,0 +1,35 @@ +using Baya.Domain.Entities.Catalog; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.CatalogConfig; + +internal sealed class NurseServiceVariantOptionConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("NurseServiceVariantOptions", "catalog"); + + // One value per dimension per variant. The unique index is the authoritative backstop; the handler + // validates the same rule for a clean message. Its leading column is variant_id, so it also serves + // "load a variant's full option set" — no separate variant_id index needed. + builder.HasIndex(o => new { o.VariantId, o.OptionGroupId }) + .IsUnique() + .HasDatabaseName("UX_NurseServiceVariantOptions_Variant_Group"); + + builder.HasOne(o => o.Variant) + .WithMany(v => v.Options) + .HasForeignKey(o => o.VariantId) + .IsRequired(); + + builder.HasOne(o => o.OptionGroup) + .WithMany() + .HasForeignKey(o => o.OptionGroupId) + .IsRequired(); + + builder.HasOne(o => o.OptionValue) + .WithMany() + .HasForeignKey(o => o.OptionValueId) + .IsRequired(); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/CatalogConfig/ServiceCategoryConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/CatalogConfig/ServiceCategoryConfig.cs new file mode 100644 index 0000000..fc83353 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/CatalogConfig/ServiceCategoryConfig.cs @@ -0,0 +1,28 @@ +using Baya.Domain.Entities.Catalog; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.CatalogConfig; + +internal sealed class ServiceCategoryConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ServiceCategories", "catalog"); + + builder.Property(c => c.NameFa).HasMaxLength(150).IsRequired(); + builder.Property(c => c.NameEn).HasMaxLength(150).IsRequired(); + builder.Property(c => c.DescriptionFa).HasMaxLength(1000); + builder.Property(c => c.DescriptionEn).HasMaxLength(1000); + builder.Property(c => c.IconKey).HasMaxLength(100); + builder.Property(c => c.SortOrder).HasDefaultValue(0); + builder.Property(c => c.IsActive).HasDefaultValue(true); + + // Public ordered browse: active categories in sort order. + builder.HasIndex(c => new { c.IsActive, c.SortOrder }); + + builder.HasQueryFilter(c => c.DeletedAt == null); + + builder.HasData(CatalogSeed.Categories()); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/CatalogConfig/ServiceOptionGroupConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/CatalogConfig/ServiceOptionGroupConfig.cs new file mode 100644 index 0000000..e3c290c --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/CatalogConfig/ServiceOptionGroupConfig.cs @@ -0,0 +1,30 @@ +using Baya.Domain.Entities.Catalog; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.CatalogConfig; + +internal sealed class ServiceOptionGroupConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ServiceOptionGroups", "catalog"); + + builder.Property(g => g.NameFa).HasMaxLength(150).IsRequired(); + builder.Property(g => g.NameEn).HasMaxLength(150).IsRequired(); + builder.Property(g => g.IsRequired).HasDefaultValue(false); + builder.Property(g => g.SortOrder).HasDefaultValue(0); + builder.Property(g => g.IsActive).HasDefaultValue(true); + + // (service_category_id, sort_order) for the applicable-groups read. The nullable FK is deliberate — + // a NULL category is the cross-category case and must not be broken by a required relationship. + builder.HasIndex(g => new { g.ServiceCategoryId, g.SortOrder }); + + builder.HasOne(g => g.ServiceCategory) + .WithMany(c => c.OptionGroups) + .HasForeignKey(g => g.ServiceCategoryId) + .IsRequired(false); + + builder.HasQueryFilter(g => g.DeletedAt == null); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/CatalogConfig/ServiceOptionValueConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/CatalogConfig/ServiceOptionValueConfig.cs new file mode 100644 index 0000000..94b7803 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/CatalogConfig/ServiceOptionValueConfig.cs @@ -0,0 +1,27 @@ +using Baya.Domain.Entities.Catalog; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.CatalogConfig; + +internal sealed class ServiceOptionValueConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ServiceOptionValues", "catalog"); + + builder.Property(v => v.NameFa).HasMaxLength(150).IsRequired(); + builder.Property(v => v.NameEn).HasMaxLength(150).IsRequired(); + builder.Property(v => v.SortOrder).HasDefaultValue(0); + builder.Property(v => v.IsActive).HasDefaultValue(true); + + builder.HasIndex(v => new { v.OptionGroupId, v.SortOrder }); + + builder.HasOne(v => v.OptionGroup) + .WithMany(g => g.Values) + .HasForeignKey(v => v.OptionGroupId) + .IsRequired(); + + builder.HasQueryFilter(v => v.DeletedAt == null); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702132758_ServiceCatalogAndNurseVariants.Designer.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702132758_ServiceCatalogAndNurseVariants.Designer.cs new file mode 100644 index 0000000..9e280b1 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702132758_ServiceCatalogAndNurseVariants.Designer.cs @@ -0,0 +1,2930 @@ +// +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("20260702132758_ServiceCatalogAndNurseVariants")] + partial class ServiceCatalogAndNurseVariants + { + /// + 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.Catalog.NurseServiceVariant", 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("DisplayName") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("OptionSetHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Price") + .HasColumnType("bigint"); + + b.Property("PriceUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ServiceCategoryId") + .HasColumnType("bigint"); + + b.Property("SessionCount") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ServiceCategoryId"); + + b.HasIndex("NurseId", "IsActive"); + + b.HasIndex("NurseId", "ServiceCategoryId", "OptionSetHash") + .IsUnique() + .HasDatabaseName("UX_NurseServiceVariants_Nurse_Category_OptionSet") + .HasFilter("[DeletedAt] IS NULL"); + + b.ToTable("NurseServiceVariants", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariantOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OptionGroupId") + .HasColumnType("bigint"); + + b.Property("OptionValueId") + .HasColumnType("bigint"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("OptionGroupId"); + + b.HasIndex("OptionValueId"); + + b.HasIndex("VariantId", "OptionGroupId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceVariantOptions_Variant_Group"); + + b.ToTable("NurseServiceVariantOptions", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceCategory", 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("DescriptionEn") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DescriptionFa") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IconKey") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + 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("IsActive", "SortOrder"); + + b.ToTable("ServiceCategories", "catalog"); + + 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 = "Elderly Care", + 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 = "Post-Surgery Recovery", + 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 = "Infant Care", + 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 = "Chronic Illness Management", + 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 = "Companionship", + NameFa = "همراهی و مراقبت روزمره", + SortOrder = 5 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", 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("IsRequired") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + 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("ServiceCategoryId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("ServiceCategoryId", "SortOrder"); + + b.ToTable("ServiceOptionGroups", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionValue", 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("OptionGroupId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("OptionGroupId", "SortOrder"); + + b.ToTable("ServiceOptionValues", "catalog"); + }); + + 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.Catalog.NurseServiceVariant", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.ServiceCategory", "ServiceCategory") + .WithMany("Variants") + .HasForeignKey("ServiceCategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Nurse"); + + b.Navigation("ServiceCategory"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariantOption", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionGroup", "OptionGroup") + .WithMany() + .HasForeignKey("OptionGroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionValue", "OptionValue") + .WithMany() + .HasForeignKey("OptionValueId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant") + .WithMany("Options") + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OptionGroup"); + + b.Navigation("OptionValue"); + + b.Navigation("Variant"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceCategory", "ServiceCategory") + .WithMany("OptionGroups") + .HasForeignKey("ServiceCategoryId"); + + b.Navigation("ServiceCategory"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionValue", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionGroup", "OptionGroup") + .WithMany("Values") + .HasForeignKey("OptionGroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OptionGroup"); + }); + + 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.Catalog.NurseServiceVariant", b => + { + b.Navigation("Options"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceCategory", b => + { + b.Navigation("OptionGroups"); + + b.Navigation("Variants"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.Navigation("Values"); + }); + + 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/20260702132758_ServiceCatalogAndNurseVariants.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702132758_ServiceCatalogAndNurseVariants.cs new file mode 100644 index 0000000..2cc2339 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260702132758_ServiceCatalogAndNurseVariants.cs @@ -0,0 +1,280 @@ +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 ServiceCatalogAndNurseVariants : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "catalog"); + + migrationBuilder.CreateTable( + name: "ServiceCategories", + schema: "catalog", + 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), + DescriptionFa = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: true), + DescriptionEn = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: true), + IconKey = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + 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_ServiceCategories", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "NurseServiceVariants", + schema: "catalog", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + NurseId = table.Column(type: "bigint", nullable: false), + ServiceCategoryId = table.Column(type: "bigint", nullable: false), + Price = table.Column(type: "bigint", nullable: false), + PriceUnit = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + SessionCount = table.Column(type: "int", nullable: true), + DisplayName = table.Column(type: "nvarchar(300)", maxLength: 300, nullable: false), + OptionSetHash = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + 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_NurseServiceVariants", x => x.Id); + table.ForeignKey( + name: "FK_NurseServiceVariants_NurseProfiles_NurseId", + column: x => x.NurseId, + principalSchema: "usr", + principalTable: "NurseProfiles", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_NurseServiceVariants_ServiceCategories_ServiceCategoryId", + column: x => x.ServiceCategoryId, + principalSchema: "catalog", + principalTable: "ServiceCategories", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "ServiceOptionGroups", + schema: "catalog", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ServiceCategoryId = table.Column(type: "bigint", nullable: true), + NameFa = table.Column(type: "nvarchar(150)", maxLength: 150, nullable: false), + NameEn = table.Column(type: "nvarchar(150)", maxLength: 150, nullable: false), + IsRequired = table.Column(type: "bit", nullable: false, defaultValue: 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_ServiceOptionGroups", x => x.Id); + table.ForeignKey( + name: "FK_ServiceOptionGroups_ServiceCategories_ServiceCategoryId", + column: x => x.ServiceCategoryId, + principalSchema: "catalog", + principalTable: "ServiceCategories", + principalColumn: "Id"); + }); + + migrationBuilder.CreateTable( + name: "ServiceOptionValues", + schema: "catalog", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + OptionGroupId = 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_ServiceOptionValues", x => x.Id); + table.ForeignKey( + name: "FK_ServiceOptionValues_ServiceOptionGroups_OptionGroupId", + column: x => x.OptionGroupId, + principalSchema: "catalog", + principalTable: "ServiceOptionGroups", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "NurseServiceVariantOptions", + schema: "catalog", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + VariantId = table.Column(type: "bigint", nullable: false), + OptionGroupId = table.Column(type: "bigint", nullable: false), + OptionValueId = table.Column(type: "bigint", nullable: false), + 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_NurseServiceVariantOptions", x => x.Id); + table.ForeignKey( + name: "FK_NurseServiceVariantOptions_NurseServiceVariants_VariantId", + column: x => x.VariantId, + principalSchema: "catalog", + principalTable: "NurseServiceVariants", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_NurseServiceVariantOptions_ServiceOptionGroups_OptionGroupId", + column: x => x.OptionGroupId, + principalSchema: "catalog", + principalTable: "ServiceOptionGroups", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_NurseServiceVariantOptions_ServiceOptionValues_OptionValueId", + column: x => x.OptionValueId, + principalSchema: "catalog", + principalTable: "ServiceOptionValues", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.InsertData( + schema: "catalog", + table: "ServiceCategories", + columns: new[] { "Id", "CreatedAt", "CreatedById", "DeletedAt", "DescriptionEn", "DescriptionFa", "IconKey", "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, null, null, null, true, null, null, "Elderly Care", "مراقبت از سالمند", 1 }, + { 2L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, null, null, null, true, null, null, "Post-Surgery Recovery", "مراقبت پس از جراحی", 2 }, + { 3L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, null, null, null, true, null, null, "Infant Care", "مراقبت از نوزاد", 3 }, + { 4L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, null, null, null, true, null, null, "Chronic Illness Management", "مدیریت بیماری مزمن", 4 }, + { 5L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, null, null, null, true, null, null, "Companionship", "همراهی و مراقبت روزمره", 5 } + }); + + migrationBuilder.CreateIndex( + name: "IX_NurseServiceVariantOptions_OptionGroupId", + schema: "catalog", + table: "NurseServiceVariantOptions", + column: "OptionGroupId"); + + migrationBuilder.CreateIndex( + name: "IX_NurseServiceVariantOptions_OptionValueId", + schema: "catalog", + table: "NurseServiceVariantOptions", + column: "OptionValueId"); + + migrationBuilder.CreateIndex( + name: "UX_NurseServiceVariantOptions_Variant_Group", + schema: "catalog", + table: "NurseServiceVariantOptions", + columns: new[] { "VariantId", "OptionGroupId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_NurseServiceVariants_NurseId_IsActive", + schema: "catalog", + table: "NurseServiceVariants", + columns: new[] { "NurseId", "IsActive" }); + + migrationBuilder.CreateIndex( + name: "IX_NurseServiceVariants_ServiceCategoryId", + schema: "catalog", + table: "NurseServiceVariants", + column: "ServiceCategoryId"); + + migrationBuilder.CreateIndex( + name: "UX_NurseServiceVariants_Nurse_Category_OptionSet", + schema: "catalog", + table: "NurseServiceVariants", + columns: new[] { "NurseId", "ServiceCategoryId", "OptionSetHash" }, + unique: true, + filter: "[DeletedAt] IS NULL"); + + migrationBuilder.CreateIndex( + name: "IX_ServiceCategories_IsActive_SortOrder", + schema: "catalog", + table: "ServiceCategories", + columns: new[] { "IsActive", "SortOrder" }); + + migrationBuilder.CreateIndex( + name: "IX_ServiceOptionGroups_ServiceCategoryId_SortOrder", + schema: "catalog", + table: "ServiceOptionGroups", + columns: new[] { "ServiceCategoryId", "SortOrder" }); + + migrationBuilder.CreateIndex( + name: "IX_ServiceOptionValues_OptionGroupId_SortOrder", + schema: "catalog", + table: "ServiceOptionValues", + columns: new[] { "OptionGroupId", "SortOrder" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "NurseServiceVariantOptions", + schema: "catalog"); + + migrationBuilder.DropTable( + name: "NurseServiceVariants", + schema: "catalog"); + + migrationBuilder.DropTable( + name: "ServiceOptionValues", + schema: "catalog"); + + migrationBuilder.DropTable( + name: "ServiceOptionGroups", + schema: "catalog"); + + migrationBuilder.DropTable( + name: "ServiceCategories", + schema: "catalog"); + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs index 93558c7..de6c114 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -98,6 +98,337 @@ namespace Baya.Infrastructure.Persistence.Migrations b.ToTable("AuditLogs", "ops"); }); + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", 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("DisplayName") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("OptionSetHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Price") + .HasColumnType("bigint"); + + b.Property("PriceUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ServiceCategoryId") + .HasColumnType("bigint"); + + b.Property("SessionCount") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ServiceCategoryId"); + + b.HasIndex("NurseId", "IsActive"); + + b.HasIndex("NurseId", "ServiceCategoryId", "OptionSetHash") + .IsUnique() + .HasDatabaseName("UX_NurseServiceVariants_Nurse_Category_OptionSet") + .HasFilter("[DeletedAt] IS NULL"); + + b.ToTable("NurseServiceVariants", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariantOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OptionGroupId") + .HasColumnType("bigint"); + + b.Property("OptionValueId") + .HasColumnType("bigint"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("OptionGroupId"); + + b.HasIndex("OptionValueId"); + + b.HasIndex("VariantId", "OptionGroupId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceVariantOptions_Variant_Group"); + + b.ToTable("NurseServiceVariantOptions", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceCategory", 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("DescriptionEn") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DescriptionFa") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IconKey") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + 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("IsActive", "SortOrder"); + + b.ToTable("ServiceCategories", "catalog"); + + 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 = "Elderly Care", + 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 = "Post-Surgery Recovery", + 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 = "Infant Care", + 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 = "Chronic Illness Management", + 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 = "Companionship", + NameFa = "همراهی و مراقبت روزمره", + SortOrder = 5 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", 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("IsRequired") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + 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("ServiceCategoryId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("ServiceCategoryId", "SortOrder"); + + b.ToTable("ServiceOptionGroups", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionValue", 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("OptionGroupId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("OptionGroupId", "SortOrder"); + + b.ToTable("ServiceOptionValues", "catalog"); + }); + modelBuilder.Entity("Baya.Domain.Entities.Configuration.PlatformConfig", b => { b.Property("Id") @@ -2243,6 +2574,72 @@ namespace Baya.Infrastructure.Persistence.Migrations .HasForeignKey("ActorUserId"); }); + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.ServiceCategory", "ServiceCategory") + .WithMany("Variants") + .HasForeignKey("ServiceCategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Nurse"); + + b.Navigation("ServiceCategory"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariantOption", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionGroup", "OptionGroup") + .WithMany() + .HasForeignKey("OptionGroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionValue", "OptionValue") + .WithMany() + .HasForeignKey("OptionValueId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant") + .WithMany("Options") + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OptionGroup"); + + b.Navigation("OptionValue"); + + b.Navigation("Variant"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceCategory", "ServiceCategory") + .WithMany("OptionGroups") + .HasForeignKey("ServiceCategoryId"); + + b.Navigation("ServiceCategory"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionValue", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionGroup", "OptionGroup") + .WithMany("Values") + .HasForeignKey("OptionGroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OptionGroup"); + }); + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => { b.HasOne("Baya.Domain.Entities.Geography.Province", "Province") @@ -2466,6 +2863,23 @@ namespace Baya.Infrastructure.Persistence.Migrations b.Navigation("User"); }); + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b => + { + b.Navigation("Options"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceCategory", b => + { + b.Navigation("OptionGroups"); + + b.Navigation("Variants"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.Navigation("Values"); + }); + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => { b.Navigation("Districts"); diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/CatalogRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/CatalogRepository.cs new file mode 100644 index 0000000..162ca89 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/CatalogRepository.cs @@ -0,0 +1,99 @@ +#nullable enable +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Catalog; +using Baya.Domain.Entities.Catalog; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Infrastructure.Persistence.Repositories; + +internal sealed class CatalogRepository : ICatalogRepository +{ + private readonly ApplicationDbContext _db; + + public CatalogRepository(ApplicationDbContext db) => _db = db; + + public async Task> ListActiveCategoriesAsync(CancellationToken cancellationToken) + => await _db.Set() + .AsNoTracking() + .Where(c => c.IsActive) + .OrderBy(c => c.SortOrder) + .ThenBy(c => c.Id) + .Select(c => new ServiceCategoryDto( + c.Id, c.NameFa, c.NameEn, c.DescriptionFa, c.DescriptionEn, c.IconKey, c.SortOrder, c.IsActive)) + .ToListAsync(cancellationToken); + + public async Task> GetApplicableGroupsAsync(long categoryId, CancellationToken cancellationToken) + => await _db.Set() + .AsNoTracking() + // The category's own active groups PLUS every cross-category (NULL) active group. + .Where(g => g.IsActive && (g.ServiceCategoryId == categoryId || g.ServiceCategoryId == null)) + .OrderBy(g => g.SortOrder) + .ThenBy(g => g.Id) + .Select(g => new OptionGroupDto( + g.Id, + g.ServiceCategoryId, + g.NameFa, + g.NameEn, + g.IsRequired, + g.SortOrder, + g.IsActive, + g.Values + .Where(v => v.IsActive) + .OrderBy(v => v.SortOrder) + .ThenBy(v => v.Id) + .Select(v => new OptionValueDto(v.Id, v.NameFa, v.NameEn, v.SortOrder, v.IsActive)) + .ToList())) + .ToListAsync(cancellationToken); + + public Task GetActiveCategoryAsync(long id, CancellationToken cancellationToken) + => _db.Set() + .AsNoTracking() + .Where(c => c.Id == id && c.IsActive) + .Select(c => new ServiceCategoryDto( + c.Id, c.NameFa, c.NameEn, c.DescriptionFa, c.DescriptionEn, c.IconKey, c.SortOrder, c.IsActive)) + .FirstOrDefaultAsync(cancellationToken); + + public Task GetGroupDtoAsync(long id, CancellationToken cancellationToken) + => _db.Set() + .AsNoTracking() + .Where(g => g.Id == id) + .Select(g => new OptionGroupDto( + g.Id, + g.ServiceCategoryId, + g.NameFa, + g.NameEn, + g.IsRequired, + g.SortOrder, + g.IsActive, + g.Values + .Where(v => v.IsActive) + .OrderBy(v => v.SortOrder) + .ThenBy(v => v.Id) + .Select(v => new OptionValueDto(v.Id, v.NameFa, v.NameEn, v.SortOrder, v.IsActive)) + .ToList())) + .FirstOrDefaultAsync(cancellationToken); + + public Task GetCategoryAsync(long id, CancellationToken cancellationToken) + => _db.Set().FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + + public Task GetGroupAsync(long id, CancellationToken cancellationToken) + => _db.Set().FirstOrDefaultAsync(g => g.Id == id, cancellationToken); + + public Task GetValueAsync(long id, CancellationToken cancellationToken) + => _db.Set().FirstOrDefaultAsync(v => v.Id == id, cancellationToken); + + public Task CategoryExistsAsync(long id, CancellationToken cancellationToken) + => _db.Set().AsNoTracking().AnyAsync(c => c.Id == id, cancellationToken); + + public Task GroupExistsAsync(long id, CancellationToken cancellationToken) + => _db.Set().AsNoTracking().AnyAsync(g => g.Id == id, cancellationToken); + + public async Task AddCategoryAsync(ServiceCategory category, CancellationToken cancellationToken) + => await _db.Set().AddAsync(category, cancellationToken); + + public async Task AddGroupAsync(ServiceOptionGroup group, CancellationToken cancellationToken) + => await _db.Set().AddAsync(group, cancellationToken); + + public async Task AddValueAsync(ServiceOptionValue value, CancellationToken cancellationToken) + => await _db.Set().AddAsync(value, cancellationToken); +} 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 373e36d..9e8dcfb 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs @@ -16,6 +16,8 @@ public class UnitOfWork : IUnitOfWork public IGeoRepository GeoRepository { get; } public INurseServiceAreaRepository NurseServiceAreaRepository { get; } public ICustomerAddressRepository CustomerAddressRepository { get; } + public ICatalogRepository CatalogRepository { get; } + public INurseServiceVariantRepository NurseServiceVariantRepository { get; } public UnitOfWork(ApplicationDbContext db) { @@ -30,6 +32,8 @@ public class UnitOfWork : IUnitOfWork GeoRepository = new GeoRepository(_db); NurseServiceAreaRepository = new NurseServiceAreaRepository(_db); CustomerAddressRepository = new CustomerAddressRepository(_db); + CatalogRepository = new CatalogRepository(_db); + NurseServiceVariantRepository = new NurseServiceVariantRepository(_db); } public Task CommitAsync() diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/NurseServiceVariantRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/NurseServiceVariantRepository.cs new file mode 100644 index 0000000..7d3a995 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/NurseServiceVariantRepository.cs @@ -0,0 +1,125 @@ +#nullable enable +using System.Globalization; +using System.Linq.Expressions; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Catalog; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.Catalog; +using Baya.Infrastructure.Persistence.Repositories.Common; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Infrastructure.Persistence.Repositories; + +internal sealed class NurseServiceVariantRepository : BaseAsyncRepository, INurseServiceVariantRepository +{ + public NurseServiceVariantRepository(ApplicationDbContext dbContext) : base(dbContext) + { + } + + public Task AddAsync(NurseServiceVariant variant, CancellationToken cancellationToken) + => base.AddAsync(variant); + + public Task GetOwnedAsync(long id, long nurseId, CancellationToken cancellationToken) + => Table.FirstOrDefaultAsync(v => v.Id == id && v.NurseId == nurseId, cancellationToken); + + public Task DuplicateHashExistsAsync(long nurseId, long serviceCategoryId, string optionSetHash, long? excludeVariantId, CancellationToken cancellationToken) + => TableNoTracking.AnyAsync( + v => v.NurseId == nurseId + && v.ServiceCategoryId == serviceCategoryId + && v.OptionSetHash == optionSetHash + && (excludeVariantId == null || v.Id != excludeVariantId), + cancellationToken); + + public async Task> ListMineAsync(long nurseId, int page, int pageSize, CancellationToken cancellationToken) + { + var query = TableNoTracking.Where(v => v.NurseId == nurseId); + + var total = await query.CountAsync(cancellationToken); + var rows = await query + // Active offerings first, then newest — the deactivated ones stay visibly distinct at the tail. + .OrderByDescending(v => v.IsActive) + .ThenByDescending(v => v.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(Projection) + .ToListAsync(cancellationToken); + + return new PagedResult(rows.Select(Map).ToList(), total, page, pageSize); + } + + public async Task GetOwnedProjectedAsync(long id, long nurseId, CancellationToken cancellationToken) + { + var row = await TableNoTracking + .Where(v => v.Id == id && v.NurseId == nurseId) + .Select(Projection) + .FirstOrDefaultAsync(cancellationToken); + return row is null ? null : Map(row); + } + + public async Task GetProjectedAsync(long id, CancellationToken cancellationToken) + { + var row = await TableNoTracking + .Where(v => v.Id == id) + .Select(Projection) + .FirstOrDefaultAsync(cancellationToken); + return row is null ? null : Map(row); + } + + public async Task GetPublicProjectedAsync(long id, CancellationToken cancellationToken) + { + var row = await TableNoTracking + .Where(v => v.Id == id && v.IsActive) + .Select(Projection) + .FirstOrDefaultAsync(cancellationToken); + return row is null ? null : Map(row); + } + + // Shared DB projection: keeps Price as the raw long (translatable) and resolves category/option labels. + // Price is formatted to a digit string in memory (see Map) so no long.ToString() SQL translation is + // required, and the option-set is a single-level collection projection (SQLite-safe). + private static readonly Expression> Projection = v => new VariantRow( + v.Id, + v.ServiceCategoryId, + v.ServiceCategory.NameFa, + v.ServiceCategory.NameEn, + v.Price, + v.PriceUnit, + v.SessionCount, + v.DisplayName, + v.IsActive, + v.Options + .OrderBy(o => o.OptionGroup.SortOrder) + .ThenBy(o => o.OptionGroupId) + .Select(o => new VariantOptionDto( + o.OptionGroupId, + o.OptionGroup.NameFa, + o.OptionGroup.NameEn, + o.OptionValueId, + o.OptionValue.NameFa, + o.OptionValue.NameEn)) + .ToList()); + + private static VariantDto Map(VariantRow r) => new( + r.Id, + r.ServiceCategoryId, + r.CategoryNameFa, + r.CategoryNameEn, + r.Price.ToString(CultureInfo.InvariantCulture), + r.PriceUnit, + r.SessionCount, + r.DisplayName, + r.IsActive, + r.Options); + + private sealed record VariantRow( + long Id, + long ServiceCategoryId, + string CategoryNameFa, + string CategoryNameEn, + long Price, + string PriceUnit, + int? SessionCount, + string DisplayName, + bool IsActive, + List Options); +} diff --git a/server/src/Tests/Baya.Test.Api/CatalogPublicApiTests.cs b/server/src/Tests/Baya.Test.Api/CatalogPublicApiTests.cs new file mode 100644 index 0000000..259af04 --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/CatalogPublicApiTests.cs @@ -0,0 +1,72 @@ +using System.Net; +using System.Net.Http.Json; + +namespace Baya.Test.Api; + +public class CatalogPublicApiTests(BayaApiFactory factory) : IClassFixture +{ + private const long ElderlyCategoryId = 1; + + [Fact] + public async Task Categories_Seeded_ReturnsFiveWithBothLabels() + { + var client = factory.CreateClient(); + + var response = await client.GetAsync("/api/v1/catalog/categories"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var data = await AuthTestClient.ReadDataAsync(response); + Assert.Equal(5, data.GetProperty("total").GetInt32()); + + var first = data.GetProperty("items").EnumerateArray().First(); + Assert.False(string.IsNullOrWhiteSpace(first.GetProperty("nameFa").GetString())); + Assert.False(string.IsNullOrWhiteSpace(first.GetProperty("nameEn").GetString())); + // Ordered by sort_order → Elderly Care (seed id/sort 1) leads. + Assert.Equal("Elderly Care", first.GetProperty("nameEn").GetString()); + } + + [Fact] + public async Task AdminBuildsDimension_PublicListsGroupWithValuesPlusCrossCategory() + { + var client = factory.CreateClient(); + await AdminTestClient.AuthenticateAsync(factory, client, "09131000001"); + + // A category-scoped required dimension. + var group = await client.PostAsJsonAsync("/api/v1/admin_catalog/create_option_group", + new { serviceCategoryId = ElderlyCategoryId, nameFa = "نوع شیفت", nameEn = "Shift type", isRequired = true, sortOrder = 1 }); + Assert.Equal(HttpStatusCode.OK, group.StatusCode); + var groupId = (await AuthTestClient.ReadDataAsync(group)).GetProperty("id").GetInt64(); + + // A cross-category (null category) dimension applies to every category. + var crossGroup = await client.PostAsJsonAsync("/api/v1/admin_catalog/create_option_group", + new { serviceCategoryId = (long?)null, nameFa = "تعداد بیمار", nameEn = "Patient count", isRequired = false, sortOrder = 2 }); + Assert.Equal(HttpStatusCode.OK, crossGroup.StatusCode); + var crossGroupId = (await AuthTestClient.ReadDataAsync(crossGroup)).GetProperty("id").GetInt64(); + + var v1 = await client.PostAsJsonAsync("/api/v1/admin_catalog/create_option_value", + new { optionGroupId = groupId, nameFa = "شبانه‌روزی", nameEn = "Live-in", sortOrder = 1 }); + var v2 = await client.PostAsJsonAsync("/api/v1/admin_catalog/create_option_value", + new { optionGroupId = groupId, nameFa = "روزانه", nameEn = "Daytime", sortOrder = 2 }); + Assert.Equal(HttpStatusCode.OK, v1.StatusCode); + Assert.Equal(HttpStatusCode.OK, v2.StatusCode); + + var groups = await client.GetAsync($"/api/v1/catalog/option_groups?category_id={ElderlyCategoryId}"); + var arr = (await AuthTestClient.ReadDataAsync(groups)).EnumerateArray().ToList(); + + var shift = arr.Single(g => g.GetProperty("id").GetInt64() == groupId); + Assert.True(shift.GetProperty("isRequired").GetBoolean()); + Assert.Equal(2, shift.GetProperty("values").GetArrayLength()); + + // The cross-category group shows up under this category too. + Assert.Contains(arr, g => g.GetProperty("id").GetInt64() == crossGroupId); + } + + [Fact] + public async Task CreateCategory_Unauthenticated_Returns401() + { + var client = factory.CreateClient(); + var response = await client.PostAsJsonAsync("/api/v1/admin_catalog/create_category", + new { nameFa = "x", nameEn = "x", sortOrder = 1 }); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } +} diff --git a/server/src/Tests/Baya.Test.Api/NurseVariantsApiTests.cs b/server/src/Tests/Baya.Test.Api/NurseVariantsApiTests.cs new file mode 100644 index 0000000..787ec0f --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/NurseVariantsApiTests.cs @@ -0,0 +1,115 @@ +using System.Net; +using System.Net.Http.Json; + +namespace Baya.Test.Api; + +public class NurseVariantsApiTests(BayaApiFactory factory) : IClassFixture +{ + private const long ElderlyCategoryId = 1; + + 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 Variant_FullLifecycle_BuildDuplicateMissingRequiredListDeactivateTenancy() + { + // --- Admin builds the required dimension for the Elderly category --- + var admin = factory.CreateClient(); + await AdminTestClient.AuthenticateAsync(factory, admin, "09132000001"); + + var groupResp = await admin.PostAsJsonAsync("/api/v1/admin_catalog/create_option_group", + new { serviceCategoryId = ElderlyCategoryId, nameFa = "نوع شیفت", nameEn = "Shift type", isRequired = true, sortOrder = 1 }); + var groupId = (await AuthTestClient.ReadDataAsync(groupResp)).GetProperty("id").GetInt64(); + + var valResp = await admin.PostAsJsonAsync("/api/v1/admin_catalog/create_option_value", + new { optionGroupId = groupId, nameFa = "شبانه‌روزی", nameEn = "Live-in", sortOrder = 1 }); + var liveInId = (await AuthTestClient.ReadDataAsync(valResp)).GetProperty("id").GetInt64(); + + // --- Nurse builds a valid variant --- + var nurse = factory.CreateClient(); + await SetUpNurseAsync(factory, nurse, "09132000002"); + + object CreatePayload(long valueId) => new + { + serviceCategoryId = ElderlyCategoryId, + options = new[] { new { optionGroupId = groupId, optionValueId = valueId } }, + price = "8000000", + priceUnit = "per_24h" + }; + + var create = await nurse.PostAsJsonAsync("/api/v1/nurse_variants/create", CreatePayload(liveInId)); + Assert.Equal(HttpStatusCode.OK, create.StatusCode); + var created = await AuthTestClient.ReadDataAsync(create); + var variantId = created.GetProperty("id").GetInt64(); + Assert.True(created.GetProperty("isActive").GetBoolean()); + Assert.Equal("8000000", created.GetProperty("price").GetString()); + // The DbContext normalises the Persian ZWNJ (نیم‌فاصله) to a space platform-wide, so assert on a + // ZWNJ-agnostic substring of the auto-generated display name (category + chosen value label). + var displayName = created.GetProperty("displayName").GetString(); + Assert.Contains("مراقبت از سالمند", displayName); + Assert.Contains("شبانه", displayName); + + // --- Public (anonymous) sees the active variant --- + var anon = factory.CreateClient(); + var publicGet = await anon.GetAsync($"/api/v1/nurse_variants/get/{variantId}"); + Assert.Equal(HttpStatusCode.OK, publicGet.StatusCode); + Assert.Equal("8000000", (await AuthTestClient.ReadDataAsync(publicGet)).GetProperty("price").GetString()); + + // --- Duplicate identical listing → 409 (clean, not a 500) --- + var duplicate = await nurse.PostAsJsonAsync("/api/v1/nurse_variants/create", CreatePayload(liveInId)); + Assert.Equal(HttpStatusCode.Conflict, duplicate.StatusCode); + + // --- Missing the required dimension → 400 --- + var missing = await nurse.PostAsJsonAsync("/api/v1/nurse_variants/create", + new { serviceCategoryId = ElderlyCategoryId, options = Array.Empty(), price = "5000000", priceUnit = "per_day" }); + Assert.Equal(HttpStatusCode.BadRequest, missing.StatusCode); + + // --- List shows the one active variant --- + var listActive = await AuthTestClient.ReadDataAsync(await nurse.GetAsync("/api/v1/nurse_variants/list")); + Assert.Equal(1, listActive.GetProperty("total").GetInt32()); + + // --- Deactivate (never delete); it stays in the list, flagged inactive --- + var deactivate = await nurse.PostAsJsonAsync($"/api/v1/nurse_variants/set_active/{variantId}", new { isActive = false }); + Assert.Equal(HttpStatusCode.OK, deactivate.StatusCode); + + var listAfter = await AuthTestClient.ReadDataAsync(await nurse.GetAsync("/api/v1/nurse_variants/list")); + Assert.Equal(1, listAfter.GetProperty("total").GetInt32()); + var row = listAfter.GetProperty("items").EnumerateArray().Single(v => v.GetProperty("id").GetInt64() == variantId); + Assert.False(row.GetProperty("isActive").GetBoolean()); + + // --- A deactivated variant drops out of the public view (404) --- + var publicGetAfter = await anon.GetAsync($"/api/v1/nurse_variants/get/{variantId}"); + Assert.Equal(HttpStatusCode.NotFound, publicGetAfter.StatusCode); + + // --- Tenancy: a different nurse cannot edit it → 404 (existence not leaked) --- + var otherNurse = factory.CreateClient(); + await SetUpNurseAsync(factory, otherNurse, "09132000003"); + var tenancy = await otherNurse.PostAsJsonAsync($"/api/v1/nurse_variants/update/{variantId}", + new { price = "9000000", priceUnit = "per_day" }); + Assert.Equal(HttpStatusCode.NotFound, tenancy.StatusCode); + } + + [Fact] + public async Task Create_Unauthenticated_Returns401() + { + var client = factory.CreateClient(); + var response = await client.PostAsJsonAsync("/api/v1/nurse_variants/create", + new { serviceCategoryId = ElderlyCategoryId, options = Array.Empty(), price = "8000000", priceUnit = "per_24h" }); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Create_InvalidPrice_Returns400() + { + var client = factory.CreateClient(); + await SetUpNurseAsync(factory, client, "09132000004"); + + var response = await client.PostAsJsonAsync("/api/v1/nurse_variants/create", + new { serviceCategoryId = ElderlyCategoryId, options = Array.Empty(), price = "-5", priceUnit = "per_24h" }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Catalog/CatalogAdminHandlerTests.cs b/server/src/Tests/Baya.Test.Foundation/Catalog/CatalogAdminHandlerTests.cs new file mode 100644 index 0000000..29b0681 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Catalog/CatalogAdminHandlerTests.cs @@ -0,0 +1,82 @@ +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Features.Catalog.Commands.CreateServiceCategory; +using Baya.Application.Features.Catalog.Commands.CreateServiceOptionGroup; +using Baya.Application.Features.Catalog.Commands.CreateServiceOptionValue; +using Baya.Application.Models.Catalog; +using Baya.Domain.Entities.Catalog; +using NSubstitute; + +namespace Baya.Test.Foundation.Catalog; + +public class CatalogAdminHandlerTests +{ + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly ICatalogRepository _catalog = Substitute.For(); + private readonly ICacheService _cache = Substitute.For(); + + public CatalogAdminHandlerTests() + { + _unitOfWork.CatalogRepository.Returns(_catalog); + } + + [Fact] + public async Task CreateCategory_Succeeds_AndInvalidatesCache() + { + var handler = new CreateServiceCategoryCommandHandler(_unitOfWork, _cache); + + var result = await handler.Handle( + new CreateServiceCategoryCommand("مراقبت از سالمند", "Elderly Care", null, null, null, 1), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("Elderly Care", result.Result.NameEn); + Assert.True(result.Result.IsActive); + await _catalog.Received(1).AddCategoryAsync(Arg.Any(), Arg.Any()); + await _unitOfWork.Received(1).CommitAsync(); + // Invalidation bumps the generation-token key so every cached catalog page refreshes. + await _cache.Received().SetAsync("catalog:version", Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task CreateOptionGroup_UnknownCategory_Fails() + { + _catalog.CategoryExistsAsync(99L, Arg.Any()).Returns(false); + var handler = new CreateServiceOptionGroupCommandHandler(_unitOfWork, _cache); + + var result = await handler.Handle( + new CreateServiceOptionGroupCommand(99L, "نوع شیفت", "Shift type", true, 1), CancellationToken.None); + + Assert.False(result.IsSuccess); + await _catalog.DidNotReceive().AddGroupAsync(Arg.Any(), Arg.Any()); + await _unitOfWork.DidNotReceive().CommitAsync(); + } + + [Fact] + public async Task CreateOptionGroup_CrossCategoryNull_Succeeds() + { + _catalog.GetGroupDtoAsync(Arg.Any(), Arg.Any()) + .Returns(new OptionGroupDto(5, null, "نوع شیفت", "Shift type", true, 1, true, [])); + var handler = new CreateServiceOptionGroupCommandHandler(_unitOfWork, _cache); + + var result = await handler.Handle( + new CreateServiceOptionGroupCommand(null, "نوع شیفت", "Shift type", true, 1), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Null(result.Result.ServiceCategoryId); + await _catalog.Received(1).AddGroupAsync( + Arg.Is(g => g.ServiceCategoryId == null && g.IsRequired), Arg.Any()); + } + + [Fact] + public async Task CreateOptionValue_UnknownGroup_Fails() + { + _catalog.GroupExistsAsync(77L, Arg.Any()).Returns(false); + var handler = new CreateServiceOptionValueCommandHandler(_unitOfWork, _cache); + + var result = await handler.Handle( + new CreateServiceOptionValueCommand(77L, "شبانه‌روزی", "Live-in", 1), CancellationToken.None); + + Assert.False(result.IsSuccess); + await _catalog.DidNotReceive().AddValueAsync(Arg.Any(), Arg.Any()); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Catalog/CreateVariantHandlerTests.cs b/server/src/Tests/Baya.Test.Foundation/Catalog/CreateVariantHandlerTests.cs new file mode 100644 index 0000000..d33fd14 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Catalog/CreateVariantHandlerTests.cs @@ -0,0 +1,149 @@ +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Features.Variants.Commands.CreateVariant; +using Baya.Application.Models.Catalog; +using Baya.Domain.Entities.Catalog; +using Baya.Domain.Entities.User; +using NSubstitute; +using NSubstitute.ReturnsExtensions; + +namespace Baya.Test.Foundation.Catalog; + +public class CreateVariantHandlerTests +{ + private readonly ICurrentUser _currentUser = Substitute.For(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly INurseProfileRepository _nurses = Substitute.For(); + private readonly ICatalogRepository _catalog = Substitute.For(); + private readonly INurseServiceVariantRepository _variants = Substitute.For(); + + private const long CategoryId = 1; + private const long ShiftGroupId = 10; + private const long LiveInValueId = 100; + private const long DaytimeValueId = 101; + + public CreateVariantHandlerTests() + { + _currentUser.UserId.Returns(7); + _currentUser.Roles.Returns([RoleNames.Nurse]); + _unitOfWork.NurseProfileRepository.Returns(_nurses); + _unitOfWork.CatalogRepository.Returns(_catalog); + _unitOfWork.NurseServiceVariantRepository.Returns(_variants); + + _nurses.GetProfileIdByUserIdAsync(7, Arg.Any()).Returns(42L); + + _catalog.GetActiveCategoryAsync(CategoryId, Arg.Any()) + .Returns(new ServiceCategoryDto(CategoryId, "مراقبت از سالمند", "Elderly Care", null, null, null, 1, true)); + + _catalog.GetApplicableGroupsAsync(CategoryId, Arg.Any()) + .Returns(new List + { + new(ShiftGroupId, CategoryId, "نوع شیفت", "Shift type", IsRequired: true, SortOrder: 1, IsActive: true, + Values: + [ + new OptionValueDto(LiveInValueId, "شبانه‌روزی", "Live-in", 1, true), + new OptionValueDto(DaytimeValueId, "روزانه", "Daytime", 2, true) + ]) + }); + + _variants.DuplicateHashExistsAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(false); + } + + private CreateVariantCommandHandler Handler() => new(_currentUser, _unitOfWork); + + private static CreateVariantCommand Command(IReadOnlyList options, string? displayName = null) + => new(CategoryId, options, "8000000", "per_24h", null, displayName); + + [Fact] + public async Task Create_ValidVariant_SucceedsWithGeneratedDisplayName() + { + var result = await Handler().Handle(Command([new(ShiftGroupId, LiveInValueId)]), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.True(result.Result.IsActive); + Assert.Equal("8000000", result.Result.Price); + Assert.Contains("مراقبت از سالمند", result.Result.DisplayName); + Assert.Contains("شبانه‌روزی", result.Result.DisplayName); + Assert.Single(result.Result.Options); + await _variants.Received(1).AddAsync( + Arg.Is(v => + v.NurseId == 42L && v.ServiceCategoryId == CategoryId && v.PriceUnit == "per_24h" + && v.Price == 8000000 && !string.IsNullOrEmpty(v.OptionSetHash) && v.Options.Count == 1), + Arg.Any()); + await _unitOfWork.Received(1).CommitAsync(); + } + + [Fact] + public async Task Create_MissingRequiredGroup_FailsValidationNotConflict() + { + var result = await Handler().Handle(Command([]), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.False(result.IsConflict); + Assert.False(result.IsNotFound); + await _variants.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Create_DuplicateOptionSet_ReturnsConflict() + { + _variants.DuplicateHashExistsAsync(42L, CategoryId, Arg.Any(), null, Arg.Any()).Returns(true); + + var result = await Handler().Handle(Command([new(ShiftGroupId, LiveInValueId)]), CancellationToken.None); + + Assert.True(result.IsConflict); + await _variants.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Create_SameGroupTwice_FailsOneValuePerDimension() + { + var result = await Handler().Handle( + Command([new(ShiftGroupId, LiveInValueId), new(ShiftGroupId, DaytimeValueId)]), CancellationToken.None); + + Assert.False(result.IsSuccess); + await _variants.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Create_ValueNotBelongingToGroup_Fails() + { + var result = await Handler().Handle(Command([new(ShiftGroupId, 999L)]), CancellationToken.None); + + Assert.False(result.IsSuccess); + await _variants.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Create_InactiveOrMissingCategory_Fails() + { + _catalog.GetActiveCategoryAsync(CategoryId, Arg.Any()).ReturnsNull(); + + var result = await Handler().Handle(Command([new(ShiftGroupId, LiveInValueId)]), CancellationToken.None); + + Assert.False(result.IsSuccess); + await _variants.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Create_NonNurse_IsForbidden() + { + _currentUser.Roles.Returns([RoleNames.Customer]); + + var result = await Handler().Handle(Command([new(ShiftGroupId, LiveInValueId)]), CancellationToken.None); + + Assert.True(result.IsForbidden); + await _variants.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Create_NurseOverridesDisplayName_UsesOverride() + { + var result = await Handler().Handle( + Command([new(ShiftGroupId, LiveInValueId)], displayName: "My live-in package"), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("My live-in package", result.Result.DisplayName); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Catalog/VariantSnapshotSerializerTests.cs b/server/src/Tests/Baya.Test.Foundation/Catalog/VariantSnapshotSerializerTests.cs new file mode 100644 index 0000000..bbdad10 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Catalog/VariantSnapshotSerializerTests.cs @@ -0,0 +1,39 @@ +using Baya.Application.Common; +using Baya.Application.Models.Catalog; + +namespace Baya.Test.Foundation.Catalog; + +public class VariantSnapshotSerializerTests +{ + [Fact] + public void Serialize_CarriesCategoryAndOptionLabels_PriceAsString_UnitAndSession() + { + var serializer = new VariantSnapshotSerializer(); + + var snapshot = new VariantSnapshot( + VariantId: 12, + ServiceCategoryId: 1, + CategoryNameFa: "مراقبت از سالمند", + CategoryNameEn: "Elderly Care", + Price: 8000000, + PriceUnit: "per_24h", + SessionCount: 3, + DisplayName: "مراقبت از سالمند · شبانه‌روزی", + Options: [new VariantOptionDto(10, "نوع شیفت", "Shift type", 100, "شبانه‌روزی", "Live-in")]); + + var json = serializer.Serialize(snapshot); + + // Money is a string of IRR-Rial digits (no floats, no numeric literal). + Assert.Contains("\"price\":\"8000000\"", json); + Assert.Contains("\"priceUnit\":\"per_24h\"", json); + Assert.Contains("\"sessionCount\":3", json); + Assert.Contains("\"variantId\":12", json); + // Category labels (both) — Persian lands as readable text, not \uXXXX. + Assert.Contains("مراقبت از سالمند", json); + Assert.Contains("Elderly Care", json); + // Each option label (group + value). + Assert.Contains("نوع شیفت", json); + Assert.Contains("شبانه‌روزی", json); + Assert.Contains("Live-in", json); + } +}