# catalog — service categories, option groups, nurse pricing variants

> Client seam `client/src/services/catalog/` · `USE_CATALOG_MOCK = false` (**real**) · 14 server ops
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).

An EAV catalogue: admin defines categories and their option groups; each nurse composes **variants** —
a category + a chosen set of option values + a price. A variant is what a customer actually books.

## Endpoints

| Method | Path | Auth | Verdict |
| --- | --- | --- | --- |
| GET | `/api/v1/catalog/categories` | **anonymous** | wired · paginated |
| GET | `/api/v1/catalog/option_groups` | **anonymous** | wired |
| GET | `/api/v1/nurse_variants/list` | `[Authorize]` | wired · paginated |
| GET | `/api/v1/nurse_variants/get/{id}` | **anonymous** | wired |
| POST | `/api/v1/nurse_variants/create` | `[Authorize]` | wired |
| POST | `/api/v1/nurse_variants/update/{id}` | `[Authorize]` | wired |
| POST | `/api/v1/nurse_variants/set_active/{id}` | `[Authorize]` | wired |
| POST | `/api/v1/admin_catalog/create_category` | admin | **unwired** — no console screen |
| POST | `/api/v1/admin_catalog/update_category/{id}` | admin | **unwired** |
| POST | `/api/v1/admin_catalog/set_category_active/{id}` | admin | **unwired** |
| POST | `/api/v1/admin_catalog/create_option_group` | admin | **unwired** |
| POST | `/api/v1/admin_catalog/update_option_group/{id}` | admin | **unwired** |
| POST | `/api/v1/admin_catalog/create_option_value` | admin | **unwired** |
| POST | `/api/v1/admin_catalog/update_option_value/{id}` | admin | **unwired** |

No phantoms. The seven `admin_catalog` routes are `DynamicPermission` + `sensitive`; the catalogue is
seeded and managed out of band today, so the console has no editor. That is a UI gap, not a contract gap.

> Mutations are **action-style, not REST**: `POST admin_catalog/create_category`, never
> `POST admin/catalog/categories`. The old contract doc calls this out explicitly and it still holds.

## Shape rules the JSON does not express

- **`GET nurse_variants/get/{id}` is anonymous** while `list` requires auth. That asymmetry is deliberate —
  a public nurse profile links to a specific variant.
- **A duplicate variant is rejected by `option_set_hash`.** The server hashes the chosen option-value set
  per (nurse, category) and enforces uniqueness, so a nurse cannot list the same configuration twice at two
  prices. The client surfaces the resulting `409`, it does not pre-check.
- **The variant snapshot is serialised at booking time** (`IVariantSnapshotSerializer`) onto the booking
  row — see [bookings.md](bookings.md). Editing or deactivating a variant never changes a past booking.
- **`set_active` is the only way to retire a variant.** There is no delete; a variant referenced by
  bookings must remain resolvable.
- **Prices are IRR digit strings** outbound. The nurse enters Toman in the UI and the client converts at
  the input boundary — the wire is always IRR.
- Reference names come as **both** `nameFa` and `nameEn`; the client picks by locale. `OptionGroupDto`
  carries its `values` inline, so the variant builder needs one round trip, not one per group.
- `isRequired` + `sortOrder` on an option group drive the builder's validation and layout — the client
  does not hardcode either.

## Enums

| Vocabulary | Values |
| --- | --- |
| `PriceUnit` | `per_hour` `per_session` `per_half_day` `per_day` `per_24h` |

`PriceUnit` is a label vocabulary, never a multiplier — the client must not derive a total from it. Display
strings are i18n keys, never the code.

## Open REQs

None. The variant builder (b7) and the Home category grid (A5) both read the contract as served.
