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
Reference in New Issue
Block a user