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>
9.3 KiB
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/money-and-types.md. Machine schema:../openapi/swagger.v1.json(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 isPOST api/v1/admin_catalog/create_category, notPOST 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;datashapes 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'spriceis quoted in.per_24h(شبانهروزی / live-in) andper_dayare 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.
priceis IRR Rials, integer, on the wire as a string of digits (e.g."8000000"). No floats, no Toman. The engagement total isprice+price_unit+session_count— never derive a total frompricealone.- 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_nameauto-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 (defaultpage_size50, 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 = nullon a group = cross-category (applies to every category).update_option_valueintentionally does not re-parent a value to another group (it would change the meaning of variants that already answered with it).- Failure cases:
400empty labels / unknown parent (serviceCategoryId/optionGroupId);401unauthenticated;403non-admin;404unknown 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? }—priceis a string of digits;optionsanswers the dimensions (one value per group). OmitdisplayNameto auto-generate it. data:VariantDto(isActive: true,displayNameauto-generated from labels unless overridden).- Failure cases:
400invalid 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.401unauthenticated;403caller is not a nurse (or has no nurse profile).409a duplicate identical listing (same category + option-set) — never a500. - Tenancy/side effects: the nurse is derived from the caller, never the body. (Deferred: this is the
trigger point for the b7
nurse_search_indexfan-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 blankdisplayNameleaves the current one unchanged.data:VariantDto.404if not owned/absent (existence not leaked).
POST api/v1/nurse_variants/set_active/{id}
- Body:
{ isActive }. Deactivate/reactivate — never hard-delete.data:true.404if 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.404when 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_unitenum; IRR-string money;409duplicate listing /400missing required dimension.
- set-active, nurse variant create/update/set-active/list/get;