backend phase 5: service catalog & nurse pricing variants
Two-tier service model the marketplace is priced and searched on. Admin catalog skeleton (categories + EAV option groups/values, addable as data not migrations; NULL category = cross-category) and the nurse pricing layer (nurse_service_variants — the atomic bookable unit: category + one value per required dimension at the nurse's own IRR price and price unit). - New `catalog` schema via one additive migration; Price BIGINT (no floats), on the wire as a string of digits; total = price + unit + session_count. - Duplicate-listing guard: deterministic option_set_hash + filtered UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULL + friendly 409 pre-check. One value per dimension; required groups (incl. cross-category) enforced; deactivate, never delete. - Public catalog browse cached behind a CatalogCache generation token, invalidated on any admin write. IVariantSnapshotSerializer shipped for b8. - Contract (catalog.md) + handoff + report published; swagger refreshed. 122 tests green; zero new build warnings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<ServiceCategoryDto>`.
|
||||||
|
|
||||||
|
### `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<VariantDto>`.
|
||||||
|
|
||||||
|
### `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<T>`: `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.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,31 @@ One block per completed backend phase. Newest at the top. Backend lane writes he
|
|||||||
- **Notes for frontend:** <anything load-bearing>
|
- **Notes for frontend:** <anything load-bearing>
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
## 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
|
## backend-phase-4 — Geography, addresses & nurse service areas — 2026-07-02
|
||||||
- **Shipped:** five tables via one migration (`GeographyAddressesServiceAreas`) — new **`geo`** schema
|
- **Shipped:** five tables via one migration (`GeographyAddressesServiceAreas`) — new **`geo`** schema
|
||||||
`Provinces` 1:N `Cities` 1:N `Districts` (+ `NurseServiceAreas`) and `usr.CustomerAddresses`; seed
|
`Provinces` 1:N `Cities` 1:N `Districts` (+ `NurseServiceAreas`) and `usr.CustomerAddresses`; seed
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
+29
-3
@@ -81,15 +81,15 @@ projects/assemblies, Clean-Architecture layers, and cross-layer dependencies.
|
|||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── Core/
|
├── 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.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; + 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.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/
|
├── 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.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.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.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier) + AddCrossCuttingSeams
|
||||||
│ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net
|
│ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net
|
||||||
├── API/
|
├── 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)
|
│ ├── 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)
|
│ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models (User only)
|
||||||
├── Shared/Baya.SharedKernel Extensions + validation base
|
├── 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`);
|
- **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.
|
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
|
**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
|
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
|
dependency — you **must** update this Project map (and the dependency rule above, if affected) in the
|
||||||
|
|||||||
@@ -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`.
|
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
|
## 7. Validation
|
||||||
|
|||||||
@@ -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<ServiceCategoryDto>]
|
||||||
|
public async Task<IActionResult> CreateCategory(CreateServiceCategoryCommand command, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(command, cancellationToken));
|
||||||
|
|
||||||
|
[HttpPost("[action]/{id}")]
|
||||||
|
[ProducesOkApiResponseType<ServiceCategoryDto>]
|
||||||
|
public async Task<IActionResult> UpdateCategory(long id, UpdateServiceCategoryCommand command, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
|
||||||
|
|
||||||
|
[HttpPost("[action]/{id}")]
|
||||||
|
[ProducesOkApiResponseType]
|
||||||
|
public async Task<IActionResult> SetCategoryActive(long id, SetServiceCategoryActiveCommand command, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
|
||||||
|
|
||||||
|
[HttpPost("[action]")]
|
||||||
|
[ProducesOkApiResponseType<OptionGroupDto>]
|
||||||
|
public async Task<IActionResult> CreateOptionGroup(CreateServiceOptionGroupCommand command, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(command, cancellationToken));
|
||||||
|
|
||||||
|
[HttpPost("[action]/{id}")]
|
||||||
|
[ProducesOkApiResponseType<OptionGroupDto>]
|
||||||
|
public async Task<IActionResult> UpdateOptionGroup(long id, UpdateServiceOptionGroupCommand command, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
|
||||||
|
|
||||||
|
[HttpPost("[action]")]
|
||||||
|
[ProducesOkApiResponseType<OptionValueDto>]
|
||||||
|
public async Task<IActionResult> CreateOptionValue(CreateServiceOptionValueCommand command, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(command, cancellationToken));
|
||||||
|
|
||||||
|
[HttpPost("[action]/{id}")]
|
||||||
|
[ProducesOkApiResponseType<OptionValueDto>]
|
||||||
|
public async Task<IActionResult> UpdateOptionValue(long id, UpdateServiceOptionValueCommand command, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
|
||||||
|
}
|
||||||
@@ -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<PagedResult<ServiceCategoryDto>>]
|
||||||
|
public async Task<IActionResult> Categories([FromQuery] GetCatalogCategoriesQuery query, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(query, cancellationToken));
|
||||||
|
|
||||||
|
[HttpGet("[action]")]
|
||||||
|
[ProducesOkApiResponseType<IReadOnlyList<OptionGroupDto>>]
|
||||||
|
public async Task<IActionResult> OptionGroups([FromQuery(Name = "category_id")] long categoryId, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(new GetCategoryOptionGroupsQuery(categoryId), cancellationToken));
|
||||||
|
}
|
||||||
@@ -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<VariantDto>]
|
||||||
|
public async Task<IActionResult> Create(CreateVariantCommand command, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(command, cancellationToken));
|
||||||
|
|
||||||
|
[HttpPost("[action]/{id}")]
|
||||||
|
[ProducesOkApiResponseType<VariantDto>]
|
||||||
|
public async Task<IActionResult> Update(long id, UpdateVariantCommand command, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
|
||||||
|
|
||||||
|
[HttpPost("[action]/{id}")]
|
||||||
|
[ProducesOkApiResponseType]
|
||||||
|
public async Task<IActionResult> SetActive(long id, SetVariantActiveCommand command, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
|
||||||
|
|
||||||
|
[HttpGet("[action]")]
|
||||||
|
[ProducesOkApiResponseType<PagedResult<VariantDto>>]
|
||||||
|
public async Task<IActionResult> 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<VariantDto>]
|
||||||
|
public async Task<IActionResult> Get(long id, CancellationToken cancellationToken)
|
||||||
|
=> OperationResult(await sender.Send(new GetVariantQuery(id), cancellationToken));
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Baya.Application.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deterministic hash of a variant's answered option-set — the key behind the duplicate-listing filtered
|
||||||
|
/// unique index (<c>UNIQUE(nurse_id, service_category_id, option_set_hash)</c>). Because the option-set is
|
||||||
|
/// multi-row, a plain composite unique index can't express "same set of choices"; hashing the sorted
|
||||||
|
/// <c>(group_id, value_id)</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Canonical implementation of <see cref="IVariantSnapshotSerializer"/>. Emits a stable, camelCase JSON
|
||||||
|
/// object carrying the category id + labels, each resolved <c>(group label, value label)</c>, 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using Baya.Application.Models.Catalog;
|
||||||
|
|
||||||
|
namespace Baya.Application.Contracts.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Emits the canonical <c>variant_snapshot_json</c> that Booking (backend-phase-8) freezes onto a
|
||||||
|
/// <c>booking_requests</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
public interface IVariantSnapshotSerializer
|
||||||
|
{
|
||||||
|
/// <summary>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).</summary>
|
||||||
|
string Serialize(VariantSnapshot snapshot);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#nullable enable
|
||||||
|
using Baya.Application.Models.Catalog;
|
||||||
|
using Baya.Domain.Entities.Catalog;
|
||||||
|
|
||||||
|
namespace Baya.Application.Contracts.Persistence;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <b>plus</b> every cross-category (NULL-category) group — the load-bearing EAV rule.
|
||||||
|
/// </summary>
|
||||||
|
public interface ICatalogRepository
|
||||||
|
{
|
||||||
|
/// <summary>All active categories, ordered by sort order — cached by the caller.</summary>
|
||||||
|
Task<IReadOnlyList<ServiceCategoryDto>> ListActiveCategoriesAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>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).</summary>
|
||||||
|
Task<IReadOnlyList<OptionGroupDto>> GetApplicableGroupsAsync(long categoryId, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Active-only category projection for variant creation — null if missing or deactivated.</summary>
|
||||||
|
Task<ServiceCategoryDto?> GetActiveCategoryAsync(long id, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
Task<OptionGroupDto?> GetGroupDtoAsync(long id, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
// Admin: tracked lookups (include inactive, exclude soft-deleted) for edit/toggle.
|
||||||
|
Task<ServiceCategory?> GetCategoryAsync(long id, CancellationToken cancellationToken);
|
||||||
|
Task<ServiceOptionGroup?> GetGroupAsync(long id, CancellationToken cancellationToken);
|
||||||
|
Task<ServiceOptionValue?> GetValueAsync(long id, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Whether a non-soft-deleted category exists (any active state) — parent check for a group.</summary>
|
||||||
|
Task<bool> CategoryExistsAsync(long id, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Whether a non-soft-deleted group exists — parent check for a value.</summary>
|
||||||
|
Task<bool> GroupExistsAsync(long id, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task AddCategoryAsync(ServiceCategory category, CancellationToken cancellationToken);
|
||||||
|
Task AddGroupAsync(ServiceOptionGroup group, CancellationToken cancellationToken);
|
||||||
|
Task AddValueAsync(ServiceOptionValue value, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
+36
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A nurse's priced offerings — the atomic bookable unit. Writes go through the owning-nurse tenancy check;
|
||||||
|
/// reads project to <see cref="VariantDto"/> with resolved category/option labels. The duplicate-listing
|
||||||
|
/// guard is a pre-check here plus the filtered unique index backstop in the EF configuration.
|
||||||
|
/// </summary>
|
||||||
|
public interface INurseServiceVariantRepository
|
||||||
|
{
|
||||||
|
Task AddAsync(NurseServiceVariant variant, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Tracked, tenancy-scoped getter for owner scalar edits/toggle. Null if not owned/absent —
|
||||||
|
/// existence of another nurse's variant is never leaked.</summary>
|
||||||
|
Task<NurseServiceVariant?> GetOwnedAsync(long id, long nurseId, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Duplicate-listing pre-check: a non-deleted variant with this exact option-set already exists
|
||||||
|
/// for the nurse+category. <paramref name="excludeVariantId"/> skips the variant being edited.</summary>
|
||||||
|
Task<bool> DuplicateHashExistsAsync(long nurseId, long serviceCategoryId, string optionSetHash, long? excludeVariantId, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>The nurse's own offerings (active + inactive), paginated, active-first, resolved labels.</summary>
|
||||||
|
Task<PagedResult<VariantDto>> ListMineAsync(long nurseId, int page, int pageSize, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Full projection for the owning nurse — any status. Null if not owned/absent.</summary>
|
||||||
|
Task<VariantDto?> GetOwnedProjectedAsync(long id, long nurseId, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Full projection for admin — any status. Null if absent.</summary>
|
||||||
|
Task<VariantDto?> GetProjectedAsync(long id, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Public-safe projection: an <b>active</b> variant only. Null when missing/inactive.</summary>
|
||||||
|
Task<VariantDto?> GetPublicProjectedAsync(long id, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ public interface IUnitOfWork
|
|||||||
public IGeoRepository GeoRepository { get; }
|
public IGeoRepository GeoRepository { get; }
|
||||||
public INurseServiceAreaRepository NurseServiceAreaRepository { get; }
|
public INurseServiceAreaRepository NurseServiceAreaRepository { get; }
|
||||||
public ICustomerAddressRepository CustomerAddressRepository { get; }
|
public ICustomerAddressRepository CustomerAddressRepository { get; }
|
||||||
|
public ICatalogRepository CatalogRepository { get; }
|
||||||
|
public INurseServiceVariantRepository NurseServiceVariantRepository { get; }
|
||||||
Task CommitAsync();
|
Task CommitAsync();
|
||||||
ValueTask RollBackAsync();
|
ValueTask RollBackAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#nullable enable
|
||||||
|
using Baya.Application.Contracts.Common;
|
||||||
|
|
||||||
|
namespace Baya.Application.Features.Catalog;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<string> VersionAsync(ICacheService cache, CancellationToken cancellationToken)
|
||||||
|
=> cache.GetOrCreateAsync(VersionKey, _ => ValueTask.FromResult(NewToken()), null, cancellationToken);
|
||||||
|
|
||||||
|
public static ValueTask InvalidateAsync(ICacheService cache, CancellationToken cancellationToken)
|
||||||
|
=> cache.SetAsync(VersionKey, NewToken(), null, cancellationToken);
|
||||||
|
|
||||||
|
public static string 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");
|
||||||
|
}
|
||||||
+35
@@ -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<CreateServiceCategoryCommand, OperationResult<ServiceCategoryDto>>
|
||||||
|
{
|
||||||
|
public async ValueTask<OperationResult<ServiceCategoryDto>> 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<ServiceCategoryDto>.SuccessResult(new ServiceCategoryDto(
|
||||||
|
category.Id, category.NameFa, category.NameEn, category.DescriptionFa, category.DescriptionEn,
|
||||||
|
category.IconKey, category.SortOrder, category.IsActive));
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace Baya.Application.Features.Catalog.Commands.CreateServiceCategory;
|
||||||
|
|
||||||
|
public sealed class CreateServiceCategoryCommandValidator : AbstractValidator<CreateServiceCategoryCommand>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>Admin: add a top-level care category (data, not code). Both labels required. Invalidates cache.</summary>
|
||||||
|
public record CreateServiceCategoryCommand(
|
||||||
|
string NameFa,
|
||||||
|
string NameEn,
|
||||||
|
string? DescriptionFa,
|
||||||
|
string? DescriptionEn,
|
||||||
|
string? IconKey,
|
||||||
|
int SortOrder) : IRequest<OperationResult<ServiceCategoryDto>>;
|
||||||
+37
@@ -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<CreateServiceOptionGroupCommand, OperationResult<OptionGroupDto>>
|
||||||
|
{
|
||||||
|
public async ValueTask<OperationResult<OptionGroupDto>> Handle(CreateServiceOptionGroupCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (request.ServiceCategoryId is { } categoryId
|
||||||
|
&& !await unitOfWork.CatalogRepository.CategoryExistsAsync(categoryId, cancellationToken))
|
||||||
|
return OperationResult<OptionGroupDto>.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<OptionGroupDto>.SuccessResult(dto!);
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace Baya.Application.Features.Catalog.Commands.CreateServiceOptionGroup;
|
||||||
|
|
||||||
|
public sealed class CreateServiceOptionGroupCommandValidator : AbstractValidator<CreateServiceOptionGroupCommand>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Admin: add a pricing dimension. <see cref="ServiceCategoryId"/> == <c>null</c> makes it
|
||||||
|
/// <b>cross-category</b> (applies to every category). Invalidates the catalog cache.
|
||||||
|
/// </summary>
|
||||||
|
public record CreateServiceOptionGroupCommand(
|
||||||
|
long? ServiceCategoryId,
|
||||||
|
string NameFa,
|
||||||
|
string NameEn,
|
||||||
|
bool IsRequired,
|
||||||
|
int SortOrder) : IRequest<OperationResult<OptionGroupDto>>;
|
||||||
+35
@@ -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<CreateServiceOptionValueCommand, OperationResult<OptionValueDto>>
|
||||||
|
{
|
||||||
|
public async ValueTask<OperationResult<OptionValueDto>> Handle(CreateServiceOptionValueCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!await unitOfWork.CatalogRepository.GroupExistsAsync(request.OptionGroupId, cancellationToken))
|
||||||
|
return OperationResult<OptionValueDto>.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<OptionValueDto>.SuccessResult(new OptionValueDto(
|
||||||
|
value.Id, value.NameFa, value.NameEn, value.SortOrder, value.IsActive));
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace Baya.Application.Features.Catalog.Commands.CreateServiceOptionValue;
|
||||||
|
|
||||||
|
public sealed class CreateServiceOptionValueCommandValidator : AbstractValidator<CreateServiceOptionValueCommand>
|
||||||
|
{
|
||||||
|
public CreateServiceOptionValueCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.OptionGroupId).GreaterThan(0);
|
||||||
|
RuleFor(x => x.NameFa).NotEmpty().MaximumLength(150);
|
||||||
|
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(150);
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
using Baya.Application.Models.Catalog;
|
||||||
|
using Baya.Application.Models.Common;
|
||||||
|
using Mediator;
|
||||||
|
|
||||||
|
namespace Baya.Application.Features.Catalog.Commands.CreateServiceOptionValue;
|
||||||
|
|
||||||
|
/// <summary>Admin: add a concrete choice to an option group. Both labels required. Invalidates cache.</summary>
|
||||||
|
public record CreateServiceOptionValueCommand(
|
||||||
|
long OptionGroupId,
|
||||||
|
string NameFa,
|
||||||
|
string NameEn,
|
||||||
|
int SortOrder) : IRequest<OperationResult<OptionValueDto>>;
|
||||||
+24
@@ -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<SetServiceCategoryActiveCommand, OperationResult<bool>>
|
||||||
|
{
|
||||||
|
public async ValueTask<OperationResult<bool>> Handle(SetServiceCategoryActiveCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var category = await unitOfWork.CatalogRepository.GetCategoryAsync(request.Id, cancellationToken);
|
||||||
|
if (category is null)
|
||||||
|
return OperationResult<bool>.NotFoundResult("Category not found.");
|
||||||
|
|
||||||
|
category.IsActive = request.IsActive;
|
||||||
|
|
||||||
|
await unitOfWork.CommitAsync();
|
||||||
|
await CatalogCache.InvalidateAsync(cache, cancellationToken);
|
||||||
|
|
||||||
|
return OperationResult<bool>.SuccessResult(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
using Baya.Application.Models.Common;
|
||||||
|
using Mediator;
|
||||||
|
|
||||||
|
namespace Baya.Application.Features.Catalog.Commands.SetServiceCategoryActive;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Admin: toggle a category's active flag. <b>Soft state only — never hard-delete.</b> 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). <see cref="Id"/> comes from the route.
|
||||||
|
/// </summary>
|
||||||
|
public record SetServiceCategoryActiveCommand(long Id, bool IsActive) : IRequest<OperationResult<bool>>;
|
||||||
+32
@@ -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<UpdateServiceCategoryCommand, OperationResult<ServiceCategoryDto>>
|
||||||
|
{
|
||||||
|
public async ValueTask<OperationResult<ServiceCategoryDto>> Handle(UpdateServiceCategoryCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var category = await unitOfWork.CatalogRepository.GetCategoryAsync(request.Id, cancellationToken);
|
||||||
|
if (category is null)
|
||||||
|
return OperationResult<ServiceCategoryDto>.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<ServiceCategoryDto>.SuccessResult(new ServiceCategoryDto(
|
||||||
|
category.Id, category.NameFa, category.NameEn, category.DescriptionFa, category.DescriptionEn,
|
||||||
|
category.IconKey, category.SortOrder, category.IsActive));
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace Baya.Application.Features.Catalog.Commands.UpdateServiceCategory;
|
||||||
|
|
||||||
|
public sealed class UpdateServiceCategoryCommandValidator : AbstractValidator<UpdateServiceCategoryCommand>
|
||||||
|
{
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>Admin: edit a category's labels/description/icon/order. <see cref="Id"/> comes from the route.</summary>
|
||||||
|
public record UpdateServiceCategoryCommand(
|
||||||
|
long Id,
|
||||||
|
string NameFa,
|
||||||
|
string NameEn,
|
||||||
|
string? DescriptionFa,
|
||||||
|
string? DescriptionEn,
|
||||||
|
string? IconKey,
|
||||||
|
int SortOrder) : IRequest<OperationResult<ServiceCategoryDto>>;
|
||||||
+34
@@ -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<UpdateServiceOptionGroupCommand, OperationResult<OptionGroupDto>>
|
||||||
|
{
|
||||||
|
public async ValueTask<OperationResult<OptionGroupDto>> Handle(UpdateServiceOptionGroupCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var group = await unitOfWork.CatalogRepository.GetGroupAsync(request.Id, cancellationToken);
|
||||||
|
if (group is null)
|
||||||
|
return OperationResult<OptionGroupDto>.NotFoundResult("Option group not found.");
|
||||||
|
|
||||||
|
if (request.ServiceCategoryId is { } categoryId
|
||||||
|
&& !await unitOfWork.CatalogRepository.CategoryExistsAsync(categoryId, cancellationToken))
|
||||||
|
return OperationResult<OptionGroupDto>.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<OptionGroupDto>.SuccessResult(dto!);
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace Baya.Application.Features.Catalog.Commands.UpdateServiceOptionGroup;
|
||||||
|
|
||||||
|
public sealed class UpdateServiceOptionGroupCommandValidator : AbstractValidator<UpdateServiceOptionGroupCommand>
|
||||||
|
{
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>Admin: edit a dimension's category scope (null = cross-category), labels, required flag, order.
|
||||||
|
/// <see cref="Id"/> comes from the route.</summary>
|
||||||
|
public record UpdateServiceOptionGroupCommand(
|
||||||
|
long Id,
|
||||||
|
long? ServiceCategoryId,
|
||||||
|
string NameFa,
|
||||||
|
string NameEn,
|
||||||
|
bool IsRequired,
|
||||||
|
int SortOrder) : IRequest<OperationResult<OptionGroupDto>>;
|
||||||
+29
@@ -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<UpdateServiceOptionValueCommand, OperationResult<OptionValueDto>>
|
||||||
|
{
|
||||||
|
public async ValueTask<OperationResult<OptionValueDto>> Handle(UpdateServiceOptionValueCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var value = await unitOfWork.CatalogRepository.GetValueAsync(request.Id, cancellationToken);
|
||||||
|
if (value is null)
|
||||||
|
return OperationResult<OptionValueDto>.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<OptionValueDto>.SuccessResult(new OptionValueDto(
|
||||||
|
value.Id, value.NameFa, value.NameEn, value.SortOrder, value.IsActive));
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace Baya.Application.Features.Catalog.Commands.UpdateServiceOptionValue;
|
||||||
|
|
||||||
|
public sealed class UpdateServiceOptionValueCommandValidator : AbstractValidator<UpdateServiceOptionValueCommand>
|
||||||
|
{
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
using Baya.Application.Models.Catalog;
|
||||||
|
using Baya.Application.Models.Common;
|
||||||
|
using Mediator;
|
||||||
|
|
||||||
|
namespace Baya.Application.Features.Catalog.Commands.UpdateServiceOptionValue;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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. <see cref="Id"/> comes from the route.
|
||||||
|
/// </summary>
|
||||||
|
public record UpdateServiceOptionValueCommand(
|
||||||
|
long Id,
|
||||||
|
string NameFa,
|
||||||
|
string NameEn,
|
||||||
|
int SortOrder,
|
||||||
|
bool IsActive) : IRequest<OperationResult<OptionValueDto>>;
|
||||||
+32
@@ -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<GetCatalogCategoriesQuery, OperationResult<PagedResult<ServiceCategoryDto>>>
|
||||||
|
{
|
||||||
|
public async ValueTask<OperationResult<PagedResult<ServiceCategoryDto>>> 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<PagedResult<ServiceCategoryDto>>.SuccessResult(
|
||||||
|
new PagedResult<ServiceCategoryDto>(items, all.Count, page, pageSize));
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
using Baya.Application.Models.Catalog;
|
||||||
|
using Baya.Application.Models.Common;
|
||||||
|
using Mediator;
|
||||||
|
|
||||||
|
namespace Baya.Application.Features.Catalog.Queries.GetCatalogCategories;
|
||||||
|
|
||||||
|
/// <summary>Public: active categories ordered by sort order, paginated. Cached reference data.</summary>
|
||||||
|
public record GetCatalogCategoriesQuery(int Page = 1, int PageSize = 50)
|
||||||
|
: IRequest<OperationResult<PagedResult<ServiceCategoryDto>>>;
|
||||||
+24
@@ -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<GetCategoryOptionGroupsQuery, OperationResult<IReadOnlyList<OptionGroupDto>>>
|
||||||
|
{
|
||||||
|
public async ValueTask<OperationResult<IReadOnlyList<OptionGroupDto>>> 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<IReadOnlyList<OptionGroupDto>>.SuccessResult(groups);
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
using Baya.Application.Models.Catalog;
|
||||||
|
using Baya.Application.Models.Common;
|
||||||
|
using Mediator;
|
||||||
|
|
||||||
|
namespace Baya.Application.Features.Catalog.Queries.GetCategoryOptionGroups;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Public: a category's <b>applicable</b> option groups — its own groups plus every cross-category (NULL)
|
||||||
|
/// group — each with its active values and <c>is_required</c>, ordered by sort order. The skeleton the
|
||||||
|
/// nurse builder fills in and the customer browses. Cached reference data.
|
||||||
|
/// </summary>
|
||||||
|
public record GetCategoryOptionGroupsQuery(long CategoryId)
|
||||||
|
: IRequest<OperationResult<IReadOnlyList<OptionGroupDto>>>;
|
||||||
+132
@@ -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<CreateVariantCommand, OperationResult<VariantDto>>
|
||||||
|
{
|
||||||
|
public async ValueTask<OperationResult<VariantDto>> Handle(CreateVariantCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } userId)
|
||||||
|
return OperationResult<VariantDto>.UnauthorizedResult("Not authenticated.");
|
||||||
|
|
||||||
|
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
|
||||||
|
return OperationResult<VariantDto>.ForbiddenResult("Only a nurse can create a variant.");
|
||||||
|
|
||||||
|
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||||
|
if (nurseId is not { } nid)
|
||||||
|
return OperationResult<VariantDto>.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<VariantDto>.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<VariantDto>.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<VariantDto>.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<VariantDto>.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<VariantDto>.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<VariantDto>.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<VariantDto>.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<VariantOptionDto> ResolveOptions(
|
||||||
|
IReadOnlyList<VariantOptionSelection> selections,
|
||||||
|
IReadOnlyDictionary<long, OptionGroupDto> 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<VariantOptionDto> options)
|
||||||
|
=> options.Count == 0
|
||||||
|
? categoryNameFa
|
||||||
|
: $"{categoryNameFa} · {string.Join(" · ", options.Select(o => o.ValueNameFa))}";
|
||||||
|
}
|
||||||
+39
@@ -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<CreateVariantCommand>
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
+20
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public record CreateVariantCommand(
|
||||||
|
long ServiceCategoryId,
|
||||||
|
IReadOnlyList<VariantOptionSelection> Options,
|
||||||
|
string Price,
|
||||||
|
string PriceUnit,
|
||||||
|
int? SessionCount,
|
||||||
|
string? DisplayName) : IRequest<OperationResult<VariantDto>>;
|
||||||
+36
@@ -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<SetVariantActiveCommand, OperationResult<bool>>
|
||||||
|
{
|
||||||
|
public async ValueTask<OperationResult<bool>> Handle(SetVariantActiveCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } userId)
|
||||||
|
return OperationResult<bool>.UnauthorizedResult("Not authenticated.");
|
||||||
|
|
||||||
|
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
|
||||||
|
return OperationResult<bool>.ForbiddenResult("Only a nurse can manage variants.");
|
||||||
|
|
||||||
|
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||||
|
if (nurseId is not { } nid)
|
||||||
|
return OperationResult<bool>.NotFoundResult("Variant not found.");
|
||||||
|
|
||||||
|
var variant = await unitOfWork.NurseServiceVariantRepository.GetOwnedAsync(request.Id, nid, cancellationToken);
|
||||||
|
if (variant is null)
|
||||||
|
return OperationResult<bool>.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<bool>.SuccessResult(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
using Baya.Application.Models.Common;
|
||||||
|
using Mediator;
|
||||||
|
|
||||||
|
namespace Baya.Application.Features.Variants.Commands.SetVariantActive;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The owning nurse activates/deactivates a variant. <b>Deactivate, never hard-delete.</b> A deactivated
|
||||||
|
/// variant cannot be booked and (via b7) drops out of the search index; its past bookings/snapshots are
|
||||||
|
/// untouched. <see cref="Id"/> comes from the route.
|
||||||
|
/// </summary>
|
||||||
|
public record SetVariantActiveCommand(long Id, bool IsActive) : IRequest<OperationResult<bool>>;
|
||||||
+46
@@ -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<UpdateVariantCommand, OperationResult<VariantDto>>
|
||||||
|
{
|
||||||
|
public async ValueTask<OperationResult<VariantDto>> Handle(UpdateVariantCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } userId)
|
||||||
|
return OperationResult<VariantDto>.UnauthorizedResult("Not authenticated.");
|
||||||
|
|
||||||
|
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
|
||||||
|
return OperationResult<VariantDto>.ForbiddenResult("Only a nurse can edit a variant.");
|
||||||
|
|
||||||
|
var nurseId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
|
||||||
|
if (nurseId is not { } nid)
|
||||||
|
return OperationResult<VariantDto>.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<VariantDto>.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<VariantDto>.NotFoundResult("Variant not found.")
|
||||||
|
: OperationResult<VariantDto>.SuccessResult(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -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<UpdateVariantCommand>
|
||||||
|
{
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
+19
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The owning nurse edits a variant's price, price unit, session count, and display name. The <b>option-set
|
||||||
|
/// is immutable</b> 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 <see cref="DisplayName"/> leaves the current one unchanged. <see cref="Id"/> comes from the route.
|
||||||
|
/// </summary>
|
||||||
|
public record UpdateVariantCommand(
|
||||||
|
long Id,
|
||||||
|
string Price,
|
||||||
|
string PriceUnit,
|
||||||
|
int? SessionCount,
|
||||||
|
string? DisplayName) : IRequest<OperationResult<VariantDto>>;
|
||||||
+43
@@ -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<GetVariantQuery, OperationResult<VariantDto>>
|
||||||
|
{
|
||||||
|
public async ValueTask<OperationResult<VariantDto>> 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<VariantDto>.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<VariantDto> Resolve(VariantDto? dto)
|
||||||
|
=> dto is null
|
||||||
|
? OperationResult<VariantDto>.NotFoundResult("Variant not found.")
|
||||||
|
: OperationResult<VariantDto>.SuccessResult(dto);
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
using Baya.Application.Models.Catalog;
|
||||||
|
using Baya.Application.Models.Common;
|
||||||
|
using Mediator;
|
||||||
|
|
||||||
|
namespace Baya.Application.Features.Variants.Queries.GetVariant;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <b>active</b> variant. Absent/inaccessible
|
||||||
|
/// resolves to not-found.
|
||||||
|
/// </summary>
|
||||||
|
public record GetVariantQuery(long Id) : IRequest<OperationResult<VariantDto>>;
|
||||||
+33
@@ -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<ListMyVariantsQuery, OperationResult<PagedResult<VariantDto>>>
|
||||||
|
{
|
||||||
|
public async ValueTask<OperationResult<PagedResult<VariantDto>>> Handle(ListMyVariantsQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } userId)
|
||||||
|
return OperationResult<PagedResult<VariantDto>>.UnauthorizedResult("Not authenticated.");
|
||||||
|
|
||||||
|
if (currentUser.Roles?.Contains(RoleNames.Nurse) != true)
|
||||||
|
return OperationResult<PagedResult<VariantDto>>.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<PagedResult<VariantDto>>.SuccessResult(
|
||||||
|
new PagedResult<VariantDto>([], 0, page, pageSize));
|
||||||
|
|
||||||
|
var result = await unitOfWork.NurseServiceVariantRepository.ListMineAsync(nid, page, pageSize, cancellationToken);
|
||||||
|
return OperationResult<PagedResult<VariantDto>>.SuccessResult(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
using Baya.Application.Models.Catalog;
|
||||||
|
using Baya.Application.Models.Common;
|
||||||
|
using Mediator;
|
||||||
|
|
||||||
|
namespace Baya.Application.Features.Variants.Queries.ListMyVariants;
|
||||||
|
|
||||||
|
/// <summary>The signed-in nurse's own offerings — active and inactive — paginated, active-first.</summary>
|
||||||
|
public record ListMyVariantsQuery(int Page = 1, int PageSize = 50)
|
||||||
|
: IRequest<OperationResult<PagedResult<VariantDto>>>;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#nullable enable
|
||||||
|
namespace Baya.Application.Models.Catalog;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A pricing dimension applicable to a category, with its active values. <c>ServiceCategoryId == null</c>
|
||||||
|
/// marks a cross-category group (applies to every category). This is the skeleton the nurse builder fills
|
||||||
|
/// in and the customer browses.
|
||||||
|
/// </summary>
|
||||||
|
public record OptionGroupDto(
|
||||||
|
long Id,
|
||||||
|
long? ServiceCategoryId,
|
||||||
|
string NameFa,
|
||||||
|
string NameEn,
|
||||||
|
bool IsRequired,
|
||||||
|
int SortOrder,
|
||||||
|
bool IsActive,
|
||||||
|
IReadOnlyList<OptionValueDto> Values);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Baya.Application.Models.Catalog;
|
||||||
|
|
||||||
|
/// <summary>A concrete choice within an option group (e.g. شبانهروزی / live-in).</summary>
|
||||||
|
public record OptionValueDto(
|
||||||
|
long Id,
|
||||||
|
string NameFa,
|
||||||
|
string NameEn,
|
||||||
|
int SortOrder,
|
||||||
|
bool IsActive);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#nullable enable
|
||||||
|
namespace Baya.Application.Models.Catalog;
|
||||||
|
|
||||||
|
/// <summary>An admin catalog category. <c>NameFa</c> is primary; the client picks the label by locale.</summary>
|
||||||
|
public record ServiceCategoryDto(
|
||||||
|
long Id,
|
||||||
|
string NameFa,
|
||||||
|
string NameEn,
|
||||||
|
string? DescriptionFa,
|
||||||
|
string? DescriptionEn,
|
||||||
|
string? IconKey,
|
||||||
|
int SortOrder,
|
||||||
|
bool IsActive);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace Baya.Application.Models.Catalog;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A nurse's priced offering with its resolved option-set. <c>Price</c> crosses the wire as a <b>string of
|
||||||
|
/// IRR-Rial digits</b> (integer money, no floats). The engagement total is <c>Price</c> + <c>PriceUnit</c>
|
||||||
|
/// + <c>SessionCount</c> — a downstream consumer derives it, never from price alone.
|
||||||
|
/// </summary>
|
||||||
|
public record VariantDto(
|
||||||
|
long Id,
|
||||||
|
long ServiceCategoryId,
|
||||||
|
string CategoryNameFa,
|
||||||
|
string CategoryNameEn,
|
||||||
|
string Price,
|
||||||
|
string PriceUnit,
|
||||||
|
int? SessionCount,
|
||||||
|
string DisplayName,
|
||||||
|
bool IsActive,
|
||||||
|
IReadOnlyList<VariantOptionDto> Options);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Baya.Application.Models.Catalog;
|
||||||
|
|
||||||
|
/// <summary>One answered dimension of a variant, with both the group and value labels resolved.</summary>
|
||||||
|
public record VariantOptionDto(
|
||||||
|
long OptionGroupId,
|
||||||
|
string GroupNameFa,
|
||||||
|
string GroupNameEn,
|
||||||
|
long OptionValueId,
|
||||||
|
string ValueNameFa,
|
||||||
|
string ValueNameEn);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
namespace Baya.Application.Models.Catalog;
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
public record VariantOptionSelection(long OptionGroupId, long OptionValueId);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace Baya.Application.Models.Catalog;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The immutable input the variant-snapshot serializer freezes onto a booking (b8) — a variant + its
|
||||||
|
/// resolved options exactly as they are at serialize time. <c>Price</c> is the raw IRR-Rial integer;
|
||||||
|
/// the serializer emits it as a string of digits per the money convention.
|
||||||
|
/// </summary>
|
||||||
|
public record VariantSnapshot(
|
||||||
|
long VariantId,
|
||||||
|
long ServiceCategoryId,
|
||||||
|
string CategoryNameFa,
|
||||||
|
string CategoryNameEn,
|
||||||
|
long Price,
|
||||||
|
string PriceUnit,
|
||||||
|
int? SessionCount,
|
||||||
|
string DisplayName,
|
||||||
|
IReadOnlyList<VariantOptionDto> Options);
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Baya.Application.Common;
|
using Baya.Application.Common;
|
||||||
|
using Baya.Application.Contracts.Common;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -21,6 +22,9 @@ public static class ServiceCollectionExtension
|
|||||||
|
|
||||||
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidateCommandBehavior<,>));
|
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidateCommandBehavior<,>));
|
||||||
|
|
||||||
|
// Pure, stateless serializer that b8 consumes to freeze a variant onto a booking.
|
||||||
|
services.AddSingleton<IVariantSnapshotSerializer, VariantSnapshotSerializer>();
|
||||||
|
|
||||||
RegisterCommandValidators(services);
|
RegisterCommandValidators(services);
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using Baya.Domain.Common;
|
||||||
|
using Baya.Domain.Entities.Identity;
|
||||||
|
|
||||||
|
namespace Baya.Domain.Entities.Catalog;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <b>atomic bookable unit</b> 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 <i>variant</i> — never on "a nurse". A nurse with no active variant is not bookable.
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="Price"/> is <b>IRR Rials as an integer</b> — no float, ever. The engagement total is
|
||||||
|
/// <see cref="Price"/> combined with <see cref="PriceUnit"/> and <see cref="SessionCount"/>; a downstream
|
||||||
|
/// consumer (booking) derives it from all three, never from price alone.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="OptionSetHash"/> is a deterministic hash of the sorted answered
|
||||||
|
/// <c>(option_group_id, option_value_id)</c> pairs. It backs the filtered
|
||||||
|
/// <c>UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULL</c> that makes the
|
||||||
|
/// duplicate-listing guard race-safe (a multi-row option-set can't be a plain composite unique index).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public class NurseServiceVariant : BaseEntity<long>
|
||||||
|
{
|
||||||
|
public long NurseId { get; set; }
|
||||||
|
public NurseProfile Nurse { get; set; }
|
||||||
|
|
||||||
|
public long ServiceCategoryId { get; set; }
|
||||||
|
public ServiceCategory ServiceCategory { get; set; }
|
||||||
|
|
||||||
|
/// <summary>IRR Rials, integer — never a float/decimal-with-fraction. There is no Toman in the DB.</summary>
|
||||||
|
public long Price { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Closed code set — see <see cref="PriceUnits"/>. The only code enum in the catalog area.</summary>
|
||||||
|
public string PriceUnit { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Number of sessions/units the engagement spans; relevant for <c>per_session</c> and packages.</summary>
|
||||||
|
public int? SessionCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Auto-generated from the option labels at create time, but nurse-editable.</summary>
|
||||||
|
public string DisplayName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Deterministic hash of the sorted answered option-set — the duplicate-listing DB backstop key.</summary>
|
||||||
|
public string OptionSetHash { get; set; }
|
||||||
|
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
|
||||||
|
public DateTimeOffset? DeletedAt { get; set; }
|
||||||
|
|
||||||
|
public ICollection<NurseServiceVariantOption> Options { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using Baya.Domain.Common;
|
||||||
|
|
||||||
|
namespace Baya.Domain.Entities.Catalog;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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. <c>UNIQUE(variant_id, option_group_id)</c>
|
||||||
|
/// enforces <b>one value per dimension per variant</b> — a variant can never answer the same group twice.
|
||||||
|
/// </summary>
|
||||||
|
public class NurseServiceVariantOption : BaseEntity<long>
|
||||||
|
{
|
||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace Baya.Domain.Entities.Catalog;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The five price units a nurse can price a variant in — the <b>only</b> closed code enum in the catalog
|
||||||
|
/// area (categories, groups, and values are data, never code constants). <c>per_24h</c> (شبانهروزی /
|
||||||
|
/// live-in) and <c>per_day</c> are first-class, not edge cases — Iranian home-nursing sells exactly these
|
||||||
|
/// shapes. Crosses the wire as the stable string code.
|
||||||
|
/// </summary>
|
||||||
|
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<string> All =
|
||||||
|
new HashSet<string> { PerHour, PerSession, PerHalfDay, PerDay, Per24H };
|
||||||
|
|
||||||
|
public static bool IsValid(string value) => value is not null && All.Contains(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using Baya.Domain.Common;
|
||||||
|
|
||||||
|
namespace Baya.Domain.Entities.Catalog;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <b>data, not code</b>: 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 <c>NameFa</c> (primary) + <c>NameEn</c> pair; the client picks by locale.
|
||||||
|
/// </summary>
|
||||||
|
public class ServiceCategory : BaseEntity<long>
|
||||||
|
{
|
||||||
|
public string NameFa { get; set; }
|
||||||
|
public string NameEn { get; set; }
|
||||||
|
|
||||||
|
public string DescriptionFa { get; set; }
|
||||||
|
public string DescriptionEn { get; set; }
|
||||||
|
|
||||||
|
/// <summary>UI glyph key the client maps to an icon; not a business value.</summary>
|
||||||
|
public string IconKey { get; set; }
|
||||||
|
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
|
||||||
|
public DateTimeOffset? DeletedAt { get; set; }
|
||||||
|
|
||||||
|
public ICollection<ServiceOptionGroup> OptionGroups { get; set; }
|
||||||
|
public ICollection<NurseServiceVariant> Variants { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using Baya.Domain.Common;
|
||||||
|
|
||||||
|
namespace Baya.Domain.Entities.Catalog;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An admin-managed configurable <b>pricing dimension</b> (e.g. نوع شیفت / shift type, تعداد بیمار /
|
||||||
|
/// patient count). This is the EAV skeleton that lets a new dimension ship as rows, not a schema change.
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="ServiceCategoryId"/> == <c>null</c> is a <b>meaningful "cross-category"</b> group: the
|
||||||
|
/// dimension applies to <i>every</i> category (e.g. shift type applies everywhere), not missing data.
|
||||||
|
/// The applicable set for a category is therefore its own groups <b>plus</b> every NULL-category group —
|
||||||
|
/// the required-group check and the duplicate guard must both honour that.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public class ServiceOptionGroup : BaseEntity<long>
|
||||||
|
{
|
||||||
|
/// <summary>NULL = cross-category (applies to every category). A real coverage choice, not unset.</summary>
|
||||||
|
public long? ServiceCategoryId { get; set; }
|
||||||
|
public ServiceCategory ServiceCategory { get; set; }
|
||||||
|
|
||||||
|
public string NameFa { get; set; }
|
||||||
|
public string NameEn { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether a variant in an applicable category must answer this dimension exactly once.</summary>
|
||||||
|
public bool IsRequired { get; set; }
|
||||||
|
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
|
||||||
|
public DateTimeOffset? DeletedAt { get; set; }
|
||||||
|
|
||||||
|
public ICollection<ServiceOptionValue> Values { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Baya.Domain.Common;
|
||||||
|
|
||||||
|
namespace Baya.Domain.Entities.Catalog;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A concrete choice inside a <see cref="ServiceOptionGroup"/> (e.g. شبانهروزی / live-in, ۲ نفر / two
|
||||||
|
/// patients). A variant answers a dimension by referencing exactly one value from that dimension's group.
|
||||||
|
/// Carries the <c>NameFa</c> (primary) + <c>NameEn</c> pair like every catalog row.
|
||||||
|
/// </summary>
|
||||||
|
public class ServiceOptionValue : BaseEntity<long>
|
||||||
|
{
|
||||||
|
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; }
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
namespace Baya.Infrastructure.Persistence.Configuration.CatalogConfig;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The five MVP service categories, seeded via <c>HasData</c> 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 <b>not</b> seeded: those are admin-authored data per category (EAV is load-bearing).
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -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<NurseServiceVariant>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<NurseServiceVariant> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -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<NurseServiceVariantOption>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<NurseServiceVariantOption> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -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<ServiceCategory>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ServiceCategory> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -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<ServiceOptionGroup>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ServiceOptionGroup> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -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<ServiceOptionValue>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ServiceOptionValue> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+2930
File diff suppressed because it is too large
Load Diff
+280
@@ -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
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ServiceCatalogAndNurseVariants : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.EnsureSchema(
|
||||||
|
name: "catalog");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ServiceCategories",
|
||||||
|
schema: "catalog",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
NameFa = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
|
||||||
|
NameEn = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
|
||||||
|
DescriptionFa = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
|
||||||
|
DescriptionEn = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
|
||||||
|
IconKey = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||||
|
SortOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
|
||||||
|
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
|
||||||
|
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||||
|
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||||
|
CreatedById = table.Column<int>(type: "int", nullable: true),
|
||||||
|
ModifiedById = table.Column<int>(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<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
NurseId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
ServiceCategoryId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
Price = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
PriceUnit = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||||
|
SessionCount = table.Column<int>(type: "int", nullable: true),
|
||||||
|
DisplayName = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: false),
|
||||||
|
OptionSetHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
|
||||||
|
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||||
|
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||||
|
CreatedById = table.Column<int>(type: "int", nullable: true),
|
||||||
|
ModifiedById = table.Column<int>(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<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
ServiceCategoryId = table.Column<long>(type: "bigint", nullable: true),
|
||||||
|
NameFa = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
|
||||||
|
NameEn = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
|
||||||
|
IsRequired = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
|
||||||
|
SortOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
|
||||||
|
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
|
||||||
|
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||||
|
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||||
|
CreatedById = table.Column<int>(type: "int", nullable: true),
|
||||||
|
ModifiedById = table.Column<int>(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<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
OptionGroupId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
NameFa = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
|
||||||
|
NameEn = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
|
||||||
|
SortOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
|
||||||
|
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
|
||||||
|
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||||
|
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||||
|
CreatedById = table.Column<int>(type: "int", nullable: true),
|
||||||
|
ModifiedById = table.Column<int>(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<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
VariantId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
OptionGroupId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
OptionValueId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||||
|
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||||
|
CreatedById = table.Column<int>(type: "int", nullable: true),
|
||||||
|
ModifiedById = table.Column<int>(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" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+414
@@ -98,6 +98,337 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("AuditLogs", "ops");
|
b.ToTable("AuditLogs", "ops");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<int?>("CreatedById")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DeletedAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(300)
|
||||||
|
.HasColumnType("nvarchar(300)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bit")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ModifiedAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<int?>("ModifiedById")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<long>("NurseId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<string>("OptionSetHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<long>("Price")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<string>("PriceUnit")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("nvarchar(20)");
|
||||||
|
|
||||||
|
b.Property<long>("ServiceCategoryId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<int?>("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<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<int?>("CreatedById")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ModifiedAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<int?>("ModifiedById")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<long>("OptionGroupId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<long>("OptionValueId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<long>("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<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<int?>("CreatedById")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DeletedAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<string>("DescriptionEn")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)");
|
||||||
|
|
||||||
|
b.Property<string>("DescriptionFa")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)");
|
||||||
|
|
||||||
|
b.Property<string>("IconKey")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bit")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ModifiedAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<int?>("ModifiedById")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("NameEn")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("nvarchar(150)");
|
||||||
|
|
||||||
|
b.Property<string>("NameFa")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("nvarchar(150)");
|
||||||
|
|
||||||
|
b.Property<int>("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<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<int?>("CreatedById")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DeletedAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bit")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<bool>("IsRequired")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bit")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ModifiedAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<int?>("ModifiedById")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("NameEn")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("nvarchar(150)");
|
||||||
|
|
||||||
|
b.Property<string>("NameFa")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("nvarchar(150)");
|
||||||
|
|
||||||
|
b.Property<long?>("ServiceCategoryId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<int>("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<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<int?>("CreatedById")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DeletedAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bit")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ModifiedAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<int?>("ModifiedById")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("NameEn")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("nvarchar(150)");
|
||||||
|
|
||||||
|
b.Property<string>("NameFa")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("nvarchar(150)");
|
||||||
|
|
||||||
|
b.Property<long>("OptionGroupId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<int>("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 =>
|
modelBuilder.Entity("Baya.Domain.Entities.Configuration.PlatformConfig", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
@@ -2243,6 +2574,72 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
|||||||
.HasForeignKey("ActorUserId");
|
.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 =>
|
modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Baya.Domain.Entities.Geography.Province", "Province")
|
b.HasOne("Baya.Domain.Entities.Geography.Province", "Province")
|
||||||
@@ -2466,6 +2863,23 @@ namespace Baya.Infrastructure.Persistence.Migrations
|
|||||||
b.Navigation("User");
|
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 =>
|
modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Districts");
|
b.Navigation("Districts");
|
||||||
|
|||||||
+99
@@ -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<IReadOnlyList<ServiceCategoryDto>> ListActiveCategoriesAsync(CancellationToken cancellationToken)
|
||||||
|
=> await _db.Set<ServiceCategory>()
|
||||||
|
.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<IReadOnlyList<OptionGroupDto>> GetApplicableGroupsAsync(long categoryId, CancellationToken cancellationToken)
|
||||||
|
=> await _db.Set<ServiceOptionGroup>()
|
||||||
|
.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<ServiceCategoryDto?> GetActiveCategoryAsync(long id, CancellationToken cancellationToken)
|
||||||
|
=> _db.Set<ServiceCategory>()
|
||||||
|
.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<OptionGroupDto?> GetGroupDtoAsync(long id, CancellationToken cancellationToken)
|
||||||
|
=> _db.Set<ServiceOptionGroup>()
|
||||||
|
.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<ServiceCategory?> GetCategoryAsync(long id, CancellationToken cancellationToken)
|
||||||
|
=> _db.Set<ServiceCategory>().FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
|
||||||
|
|
||||||
|
public Task<ServiceOptionGroup?> GetGroupAsync(long id, CancellationToken cancellationToken)
|
||||||
|
=> _db.Set<ServiceOptionGroup>().FirstOrDefaultAsync(g => g.Id == id, cancellationToken);
|
||||||
|
|
||||||
|
public Task<ServiceOptionValue?> GetValueAsync(long id, CancellationToken cancellationToken)
|
||||||
|
=> _db.Set<ServiceOptionValue>().FirstOrDefaultAsync(v => v.Id == id, cancellationToken);
|
||||||
|
|
||||||
|
public Task<bool> CategoryExistsAsync(long id, CancellationToken cancellationToken)
|
||||||
|
=> _db.Set<ServiceCategory>().AsNoTracking().AnyAsync(c => c.Id == id, cancellationToken);
|
||||||
|
|
||||||
|
public Task<bool> GroupExistsAsync(long id, CancellationToken cancellationToken)
|
||||||
|
=> _db.Set<ServiceOptionGroup>().AsNoTracking().AnyAsync(g => g.Id == id, cancellationToken);
|
||||||
|
|
||||||
|
public async Task AddCategoryAsync(ServiceCategory category, CancellationToken cancellationToken)
|
||||||
|
=> await _db.Set<ServiceCategory>().AddAsync(category, cancellationToken);
|
||||||
|
|
||||||
|
public async Task AddGroupAsync(ServiceOptionGroup group, CancellationToken cancellationToken)
|
||||||
|
=> await _db.Set<ServiceOptionGroup>().AddAsync(group, cancellationToken);
|
||||||
|
|
||||||
|
public async Task AddValueAsync(ServiceOptionValue value, CancellationToken cancellationToken)
|
||||||
|
=> await _db.Set<ServiceOptionValue>().AddAsync(value, cancellationToken);
|
||||||
|
}
|
||||||
+4
@@ -16,6 +16,8 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
public IGeoRepository GeoRepository { get; }
|
public IGeoRepository GeoRepository { get; }
|
||||||
public INurseServiceAreaRepository NurseServiceAreaRepository { get; }
|
public INurseServiceAreaRepository NurseServiceAreaRepository { get; }
|
||||||
public ICustomerAddressRepository CustomerAddressRepository { get; }
|
public ICustomerAddressRepository CustomerAddressRepository { get; }
|
||||||
|
public ICatalogRepository CatalogRepository { get; }
|
||||||
|
public INurseServiceVariantRepository NurseServiceVariantRepository { get; }
|
||||||
|
|
||||||
public UnitOfWork(ApplicationDbContext db)
|
public UnitOfWork(ApplicationDbContext db)
|
||||||
{
|
{
|
||||||
@@ -30,6 +32,8 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
GeoRepository = new GeoRepository(_db);
|
GeoRepository = new GeoRepository(_db);
|
||||||
NurseServiceAreaRepository = new NurseServiceAreaRepository(_db);
|
NurseServiceAreaRepository = new NurseServiceAreaRepository(_db);
|
||||||
CustomerAddressRepository = new CustomerAddressRepository(_db);
|
CustomerAddressRepository = new CustomerAddressRepository(_db);
|
||||||
|
CatalogRepository = new CatalogRepository(_db);
|
||||||
|
NurseServiceVariantRepository = new NurseServiceVariantRepository(_db);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task CommitAsync()
|
public Task CommitAsync()
|
||||||
|
|||||||
+125
@@ -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<NurseServiceVariant>, INurseServiceVariantRepository
|
||||||
|
{
|
||||||
|
public NurseServiceVariantRepository(ApplicationDbContext dbContext) : base(dbContext)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task AddAsync(NurseServiceVariant variant, CancellationToken cancellationToken)
|
||||||
|
=> base.AddAsync(variant);
|
||||||
|
|
||||||
|
public Task<NurseServiceVariant?> GetOwnedAsync(long id, long nurseId, CancellationToken cancellationToken)
|
||||||
|
=> Table.FirstOrDefaultAsync(v => v.Id == id && v.NurseId == nurseId, cancellationToken);
|
||||||
|
|
||||||
|
public Task<bool> 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<PagedResult<VariantDto>> 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<VariantDto>(rows.Select(Map).ToList(), total, page, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<VariantDto?> 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<VariantDto?> 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<VariantDto?> 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<Func<NurseServiceVariant, VariantRow>> 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<VariantOptionDto> Options);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
|
||||||
|
namespace Baya.Test.Api;
|
||||||
|
|
||||||
|
public class CatalogPublicApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
|
||||||
|
namespace Baya.Test.Api;
|
||||||
|
|
||||||
|
public class NurseVariantsApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
|
||||||
|
{
|
||||||
|
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<object>(), 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<object>(), 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<object>(), price = "-5", priceUnit = "per_24h" });
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IUnitOfWork>();
|
||||||
|
private readonly ICatalogRepository _catalog = Substitute.For<ICatalogRepository>();
|
||||||
|
private readonly ICacheService _cache = Substitute.For<ICacheService>();
|
||||||
|
|
||||||
|
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<ServiceCategory>(), Arg.Any<CancellationToken>());
|
||||||
|
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<string>(), Arg.Any<TimeSpan?>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateOptionGroup_UnknownCategory_Fails()
|
||||||
|
{
|
||||||
|
_catalog.CategoryExistsAsync(99L, Arg.Any<CancellationToken>()).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<ServiceOptionGroup>(), Arg.Any<CancellationToken>());
|
||||||
|
await _unitOfWork.DidNotReceive().CommitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateOptionGroup_CrossCategoryNull_Succeeds()
|
||||||
|
{
|
||||||
|
_catalog.GetGroupDtoAsync(Arg.Any<long>(), Arg.Any<CancellationToken>())
|
||||||
|
.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<ServiceOptionGroup>(g => g.ServiceCategoryId == null && g.IsRequired), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateOptionValue_UnknownGroup_Fails()
|
||||||
|
{
|
||||||
|
_catalog.GroupExistsAsync(77L, Arg.Any<CancellationToken>()).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<ServiceOptionValue>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ICurrentUser>();
|
||||||
|
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
|
||||||
|
private readonly INurseProfileRepository _nurses = Substitute.For<INurseProfileRepository>();
|
||||||
|
private readonly ICatalogRepository _catalog = Substitute.For<ICatalogRepository>();
|
||||||
|
private readonly INurseServiceVariantRepository _variants = Substitute.For<INurseServiceVariantRepository>();
|
||||||
|
|
||||||
|
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<CancellationToken>()).Returns(42L);
|
||||||
|
|
||||||
|
_catalog.GetActiveCategoryAsync(CategoryId, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new ServiceCategoryDto(CategoryId, "مراقبت از سالمند", "Elderly Care", null, null, null, 1, true));
|
||||||
|
|
||||||
|
_catalog.GetApplicableGroupsAsync(CategoryId, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<OptionGroupDto>
|
||||||
|
{
|
||||||
|
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<long>(), Arg.Any<long>(), Arg.Any<string>(), Arg.Any<long?>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CreateVariantCommandHandler Handler() => new(_currentUser, _unitOfWork);
|
||||||
|
|
||||||
|
private static CreateVariantCommand Command(IReadOnlyList<VariantOptionSelection> 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<NurseServiceVariant>(v =>
|
||||||
|
v.NurseId == 42L && v.ServiceCategoryId == CategoryId && v.PriceUnit == "per_24h"
|
||||||
|
&& v.Price == 8000000 && !string.IsNullOrEmpty(v.OptionSetHash) && v.Options.Count == 1),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
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<NurseServiceVariant>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_DuplicateOptionSet_ReturnsConflict()
|
||||||
|
{
|
||||||
|
_variants.DuplicateHashExistsAsync(42L, CategoryId, Arg.Any<string>(), null, Arg.Any<CancellationToken>()).Returns(true);
|
||||||
|
|
||||||
|
var result = await Handler().Handle(Command([new(ShiftGroupId, LiveInValueId)]), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.IsConflict);
|
||||||
|
await _variants.DidNotReceive().AddAsync(Arg.Any<NurseServiceVariant>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<NurseServiceVariant>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<NurseServiceVariant>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_InactiveOrMissingCategory_Fails()
|
||||||
|
{
|
||||||
|
_catalog.GetActiveCategoryAsync(CategoryId, Arg.Any<CancellationToken>()).ReturnsNull();
|
||||||
|
|
||||||
|
var result = await Handler().Handle(Command([new(ShiftGroupId, LiveInValueId)]), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
await _variants.DidNotReceive().AddAsync(Arg.Any<NurseServiceVariant>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<NurseServiceVariant>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user