Files
baya-monorepo/archive/build-chain/contracts/domains/catalog.md
T
2026-08-02 18:48:32 +03:30

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 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=&pageSize=

  • Active categories ordered by sortOrder, paginated (default pageSize 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=&pageSize=

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