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:
hamid
2026-07-02 22:21:53 +03:30
parent 4b4243c451
commit f77a23cb25
84 changed files with 8229 additions and 4 deletions
+142
View File
@@ -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>
-->
## backend-phase-5 — Service catalog & nurse pricing variants — 2026-07-02
- **Shipped:** five tables via one additive migration (`ServiceCatalogAndNurseVariants`) — new **`catalog`**
schema `ServiceCategories` / `ServiceOptionGroups` (nullable `service_category_id` = cross-category) /
`ServiceOptionValues` / `NurseServiceVariants` (`Price` **BIGINT IRR**, `PriceUnit`, `SessionCount?`,
`DisplayName`, `OptionSetHash`) / `NurseServiceVariantOptions` (`UNIQUE(variant_id, option_group_id)`);
seed of **5 categories** (`nameFa`+`nameEn`) via `HasData`. 16 CQRS slices across 3 controllers
(`catalog` public browse, `admin_catalog` CRUD + set-active, `nurse_variants` create/update/set-active/
list/get). Duplicate-listing guard = `OptionSetHash` + filtered `UNIQUE(nurse_id, service_category_id,
option_set_hash) WHERE deleted_at IS NULL` + 409 pre-check. Public catalog reads cached behind a
`CatalogCache` generation token (invalidate on any admin write). Ships **`IVariantSnapshotSerializer`**
(pure, for b8). **No new seam.**
- **Contracts:** dev/contracts/domains/catalog.md + openapi snapshot refreshed (yes — 14 new
catalog/admin_catalog/nurse_variants paths; 71 total).
- **Mocked:** none — this phase mocks nothing and adds **no** `reports/mocks-registry.md` row.
- **Gate:** build clean (0 new code warnings) / tests green (122 pass: +10 handler/serializer unit,
+11 `Baya.Test.Api` integration). Migration verified to apply on a real SQL Server; swagger exposes all
b5 paths. Adversarial 4-dimension review: 0 confirmed findings.
- **Handoff:** backend/handoff/after-backend-phase-5.md
- **Notes for frontend:** the **variant is the bookable unit** (not the nurse). `price` is a **string of IRR
digits**; the total is `price` + `priceUnit` + `sessionCount`, never price alone. A **NULL-category option
group is cross-category** (render it under every category; required ones must be answered). Duplicate
identical listing → **409**; missing required dimension → **400**. `displayName` auto-generates (editable).
Deactivate, never delete. Routes are action-style POST (`admin_catalog/create_category`,
`nurse_variants/create`, …); groups/values are admin-authored (only categories are seeded).
## backend-phase-4 — Geography, addresses & nurse service areas — 2026-07-02
- **Shipped:** five tables via one migration (`GeographyAddressesServiceAreas`) — new **`geo`** schema
`Provinces` 1:N `Cities` 1:N `Districts` (+ `NurseServiceAreas`) and `usr.CustomerAddresses`; seed
@@ -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 15. 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 15) 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.