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