Files
baya-monorepo/dev/phases/backend/backend-phase-5.md
T
2026-06-28 21:59:59 +03:30

35 KiB

Backend Phase 5 — Service catalog & nurse pricing variants

Mission: stand up the two-tier service model that the entire marketplace is priced and searched on. First the admin catalog skeleton — top-level care categories plus EAV-style configurable option groups and option values so new pricing dimensions ship as data, not migrations. Then the nurse pricing layer — the nurse_service_variants that are the atomic bookable unit of the whole platform: a nurse + a category + one chosen value per required dimension, at the nurse's own price and price unit. Transparent, upfront, nurse-set pricing is a deliberate differentiator versus the opaque "توافقی / negotiable" incumbents — and after this phase, search (b7), booking (b8), and every downstream money calculation operate on a variant, never on a nurse.

Track: backend · Depends on: backend-phase-3 (nurse_profiles, identity/roles), backend-phase-1 (marketplace migration baseline, seed/config, admin auth) · Unlocks: search & matching (backend-phase-7), booking requests & lifecycle (backend-phase-8), and the catalog browse + service-builder UI (frontend-phase-4-b5) Before you start, read ../_shared/agent-operating-rules.md. It is not optional.


1. Context — where this sits

This is the catalog & pricing phase. Identity exists (b2/b3): there are users with a role (admin/nurse/customer) and a gender, and every nurse has a nurse_profiles row. Geography exists (b4): provinces/cities/districts are seeded and nurse_service_areas declares where a nurse will travel. Config & reference (b1) gave us the first marketplace migration baseline, the typed cached config accessor, admin auth, and the support_alerts/notifications plumbing. None of that defines what a nurse sells or for how much — that is this phase.

The product models catalog in three admin layers (category → option group → option value) and two nurse layers (variant → variant option). The admin layers are an intentionally EAV-style configurable structure: an admin can introduce a new pricing dimension ("live-in", "number of patients") as rows, with no schema migration. The nurse layers turn that skeleton into priced, bookable offerings. The output of this phase is the thing the customer actually pays for: a variant.

What already exists (do not rebuild):

  • Identity, roles & nurse profilesbackend-phase-3 built nurse_profiles (1:1 → users, carrying is_verified, is_accepting_bookings, and the denormalized average_rating/total_reviews/total_completed_bookings aggregates), customer_profiles, patients, and nurse_bank_accounts. backend-phase-2 established phone-OTP auth, sessions, and users.gender (male/female). Variants FK to nurse_profiles — read that entity; do not re-model it. Tenancy ("only the owning nurse edits their variants") keys off ICurrentUsernurse_profiles.
  • Config, admin auth & the migration baselinebackend-phase-1 created the first marketplace EF Core migration (the baseline every later phase extends additively), platform_configs (typed cached accessor), audit_logs (written by the SaveChanges interceptor), the in-app notifications write, and the support_alerts raise API. Your migration is additive on top of b1's baseline. Admin endpoints use the admin policy established by b1/b2 — reuse it.
  • Geographybackend-phase-4 built provinces/cities/districts (+ seed) and nurse_service_areas (district_id = NULL ⇒ whole city; UNIQUE(nurse_id, city_id, district_id)). Catalog itself does not touch geography — but b7 fans a variant out across its nurse's service areas into the search index, so keep the variant shape clean for that projection.
  • Cross-cutting seamsbackend-phase-0 introduced ICacheService, IDateTimeProvider, IFieldEncryptor, IObjectStorage, and INotificationDispatcher, plus the REST surface (BaseController, snake_case routing, rate limiting), the CQRS pipeline (ISender/ICommand/IQuery, ValidateCommandBehavior, OperationResult<T>), and the audit-field interceptor. Reuse ICacheService for the read-heavy public catalog reads; do not introduce new seams.

The denormalized nurse_search_index, the INurseSearch search seam, and the index-maintenance hooks that fan a variant out per covered area are owned by backend-phase-7, not this phase. You build the variant as a clean, projectable source; b7 reads it. Do not create the search index or a search query here. (DEFERRED → b7.)

The variant_snapshot_json that freezes a variant onto a booking at booking time is owned by the Booking area (backend-phase-8). This phase ships a variant-snapshot serializer (a pure library function — §3.4) that b8 consumes; it is not an endpoint here. (snapshot persistence DEFERRED → b8.)

nurse_availability_slots / nurse_availability_exceptions are soft scheduling guidance only — not on the money or safety path. They are (DEFERRED) for MVP; see product/data-model/03-services-and-pricing.md. Note them in your report; do not build them.

2. Required reading (do this first)

  • ../_shared/agent-operating-rules.md and ../_shared/backend-conventions-checklist.md.
  • Product — business rules (source of truth): product/business/03-service-catalog-and-pricing.md — admin defines the catalog skeleton (categories + configurable option groups/values, addable without a schema change); each nurse defines their own variants (category + chosen option combination + own price + price unit); the five price units (per_hour/per_session/per_half_day/per_day/per_24h); the auto-generated-but-editable display_name; deactivate, never delete; and that catalog is snapshotted onto the booking. Read (b) Iran-specific — why per_24h/per_day are first-class and why upfront pricing is the differentiator.
  • Product — data model (source of truth): product/data-model/03-services-and-pricing.md — the three admin layers + two nurse layers, why the EAV configurability is load-bearing, why the variant is the bookable unit (not the nurse), the UNIQUE(variant_id, option_group_id) "one value per dimension" rule, the NULL service_category_id = cross-category rule, and the "consider a uniqueness strategy on (nurse_id, category, option-set)" guidance you will implement as the duplicate-listing guard.
  • Type & money rules on the wire: ../../contracts/conventions/money-and-types.mdprice is IRR Rials as an integer (BIGINT), no floats, and money crosses the wire as a string of digits; the price_unit enum is a stable string code; name_fa/name_en reference data returns both.
  • Code to mirror (existing patterns): an existing feature folder under Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/ (request record + internal sealed handler + OperationResult, validator picked up by ValidateCommandBehavior), an IEntityTypeConfiguration<T> under Persistence/Configuration/<Area>Config/, a sealed controller under Baya.Web.Api/Controllers/V1/ (BaseController, inject ISender, [controller]/[action] snake_case tokens, base.OperationResult(...)), the b1 reference-data seed pattern (how seeded name_fa/name_en rows were inserted in the baseline migration), and how reads use AsNoTracking() + .Select() projection
    • pagination + ICacheService.
  • Contract conventions: ../../contracts/conventions/api-conventions.md (envelope, snake_case routes, status codes, mandatory list pagination, localisation of name_fa/name_en).
  • Prior handoffs: dev/shared-working-context/backend/handoff/after-backend-phase-3.md (the nurse_profiles shape + the admin/nurse policies) and .../after-backend-phase-1.md (the migration baseline + config accessor + admin policy).

3. Scope — build this

A vertical slice per capability: entity + EF config + migration → command/query handler(s) → controller endpoint → contract. Everything async with CancellationToken; reads are AsNoTracking() + .Select() projection + pagination; writes go through IUnitOfWork with a single CommitAsync. Money is IRR BIGINT.

3.1 Entities, configs & migration

Add these five tables as one additive EF Core migration on top of b1's baseline. One IEntityTypeConfiguration<T> per entity in Persistence/Configuration/CatalogConfig/. All catalog rows carry a name_fa (primary) + name_en pair — never persist a category/group/value without the Persian label.

  • service_categories — admin-managed top-level care types; the primary search dimension.
    • Columns: id (BIGINT PK), name_fa (NVARCHAR, required), name_en (NVARCHAR, required), description_fa/description_en (NVARCHAR, nullable), icon_key (NVARCHAR, nullable — UI glyph), sort_order (INT, for ordered display), is_active (BIT, default 1), plus the audit fields stamped by the interceptor (CreatedAt/ModifiedAt/CreatedById/ModifiedById) and soft-delete (deleted_at).
    • Seed the five MVP categories with both labels: Elderly Care (مراقبت از سالمند), Post-Surgery Recovery (مراقبت پس از جراحی), Infant Care (مراقبت از نوزاد), Chronic Illness Management (مدیریت بیماری مزمن), Companionship (همراهی / مراقبت روزمره). Seed in the migration (b1 baseline pattern) so a nurse can build a variant immediately.
    • Index: (is_active, sort_order) for the public ordered list; soft-delete query filter (!IsDeleted).
  • service_option_groups — admin-managed configurable dimensions (e.g. نوع شیفت / shift type, تعداد بیمار / patient count).
    • Columns: id (BIGINT PK), service_category_id (BIGINT FK → service_categories, NULLABLENULL = cross-category, applies everywhere), name_fa/name_en (required), is_required (BIT — whether a variant must answer this group), sort_order (INT), is_active (BIT), + audit + soft-delete.
    • Index: (service_category_id, sort_order); soft-delete query filter. The nullable FK is deliberate — enforce nothing that breaks the cross-category (NULL) case.
  • service_option_values — the concrete choices within a group (e.g. شبانه‌روزی, ۲ نفر).
    • Columns: id (BIGINT PK), option_group_id (BIGINT FK → service_option_groups), name_fa/name_en (required), sort_order (INT), is_active (BIT), + audit + soft-delete.
    • Index: (option_group_id, sort_order); soft-delete query filter.
  • nurse_service_variantsthe atomic bookable unit. A nurse + category + chosen option combination at a price.
    • Columns: id (BIGINT PK), nurse_id (BIGINT FK → nurse_profiles), service_category_id (BIGINT FK → service_categories), price (BIGINT — IRR Rials, integer, no float, ever), price_unit (NVARCHAR stable code — closed set per_hour | per_session | per_half_day | per_day | per_24h), session_count (INT, nullable — number of sessions/units the engagement spans; relevant for per_session and packages), display_name (NVARCHAR — auto-generated from the option labels, but nurse-editable), is_active (BIT, default 1 — deactivation, never hard-delete), + audit + soft-delete.
    • Indexes / constraints: index on (nurse_id, is_active) for the nurse's offerings list and the b7 index projection; index on service_category_id; the duplicate-listing guard (§3.3) on (nurse_id, service_category_id, option-set); soft-delete query filter (!IsDeleted).
    • price_unit is the only value in this area allowed to be a closed code enum — categories, groups, and values are data, never code constants (EAV is load-bearing).
  • nurse_service_variant_options — the option values that define one variant's configuration.
    • Columns: id (BIGINT PK), variant_id (BIGINT FK → nurse_service_variants), option_group_id (BIGINT FK → service_option_groups), option_value_id (BIGINT FK → service_option_values), + audit.
    • UNIQUE(variant_id, option_group_id)one value per dimension per variant (a variant cannot answer the same group twice). Index (variant_id) for loading a variant's full option set.

Do not add nurse_search_index here. It is b7's denormalized read model. Do not add variant_snapshot_json — it lives on booking_requests and is owned by b8. Do not add nurse_availability_slots/_exceptions — DEFERRED.

3.2 Admin catalog — commands & queries

Feature folder Baya.Application/Features/Catalog/. Admin-only (narrowest admin policy from b1/b2); these mutate the platform skeleton.

  • CreateServiceCategoryCommand / UpdateServiceCategoryCommand (Commands/CreateServiceCategory/, .../UpdateServiceCategory/) — set name_fa/name_en (both required), descriptions, icon_key, sort_order. FluentValidation: both labels non-empty.
  • SetServiceCategoryActiveCommand (Commands/SetServiceCategoryActive/) — activate/deactivate (is_active). Soft state only — never hard-delete. Deactivating a category hides it from public browse and from new variant creation; existing variants in that category are left intact (their bookings/history must survive — see §5).
  • CreateServiceOptionGroupCommand / UpdateServiceOptionGroupCommand (Commands/CreateServiceOptionGroup/, .../UpdateServiceOptionGroup/) — set service_category_id (nullable — NULL marks the group cross-category), name_fa/name_en, is_required, sort_order.
  • CreateServiceOptionValueCommand / UpdateServiceOptionValueCommand (Commands/CreateServiceOptionValue/, .../UpdateServiceOptionValue/) — set option_group_id, name_fa/name_en, sort_order, is_active.
  • GetCatalogCategoriesQuery (Queries/GetCatalogCategories/) — public, paginated, is_active categories ordered by sort_order, returning name_fa/name_en. AsNoTracking() + .Select(); cache through ICacheService with invalidation on any category mutation (read-heavy reference data).
  • GetCategoryOptionGroupsQuery (Queries/GetCategoryOptionGroups/) — public, for a category id, returns its applicable option groups (the category's own groups plus all cross-category (NULL-category) groups), each with its active option values, is_required, ordered by sort_order. This is the skeleton the nurse builder fills in and the customer browses. Cache + invalidate on group/value mutation.

3.3 Nurse variants — commands & queries

Feature folder Baya.Application/Features/Variants/ (or a Catalog/Variants/ sub-area, matching the surrounding convention). Nurse-owner-only for writes; tenancy via ICurrentUsernurse_profiles.

  • CreateVariantCommand (Commands/CreateVariant/) — the nurse picks a service_category_id, supplies the chosen option_value_id for each required group (and any optional groups they answer), sets price (IRR BIGINT) + price_unit (one of the five) + optional session_count, and an optional display_name override. The handler, in one transaction:
    1. Validate the category exists and is active.
    2. Resolve applicable groups (the category's groups + cross-category groups) and assert every is_required group is answered exactly once and no group is answered twice (one value per dimension); reject unknown groups/values, or a value that does not belong to its claimed group.
    3. Duplicate-listing guard: reject if this nurse already has a non-deleted variant in the same category with the identical answered option-set (same set of (option_group_id, option_value_id) pairs). Enforce with a DB backstop (see below) and an explicit pre-check returning a clean conflict OperationResult, never a raw DB exception.
    4. Auto-generate display_name from the chosen option labels (e.g. category + " · " + value labels) when the nurse did not override it.
    5. Insert the variant + its nurse_service_variant_options rows; CommitAsync once.
    • FluentValidation: price > 0, price_unit in the closed set, session_count null or > 0, at least the required groups present.
    • DB backstop for the duplicate guard: because the option-set is multi-row, a plain composite unique index is insufficient. Persist a deterministic option_set_hash column on nurse_service_variants (a stable hash of the sorted (option_group_id, option_value_id) pairs, computed in the handler) and put a filtered UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULL on it. This makes the guard race-safe; the pre-check gives the friendly message. (If b1's conventions already established a canonical hashing helper, reuse it.)
  • UpdateVariantCommand (Commands/UpdateVariant/) — owner edits price, price_unit, session_count, and display_name. Re-validate price/unit; if the edit would collide with another of the nurse's variants' option-set, reject. (Changing the option-set itself is treated as create-new + deactivate-old to keep historical meaning stable — do not silently mutate a variant's dimensions; if you do allow option edits, recompute option_set_hash and re-run the duplicate guard.)
  • SetVariantActiveCommand (Commands/SetVariantActive/) — owner activates/deactivates (is_active). Deactivate, never hard-delete. A deactivated variant cannot be booked and (via b7) must drop out of the search index. Editing display_name may piggyback as EditVariantDisplayNameCommand or be folded into UpdateVariantCommand — match the surrounding granularity.
  • ListMyVariantsQuery (Queries/ListMyVariants/) — the signed-in nurse's own offerings, active and inactive, paginated, each with category label, resolved option labels, price, unit, session_count, display_name, is_active. AsNoTracking() + .Select() projection.
  • GetVariantQuery (Queries/GetVariant/) — a single variant with its full resolved option-set and labels, for the edit screen and for b8's booking-request capture to read the canonical offering. Owner or admin for the full view; a public-safe projection (price/unit/labels, no internal fields) backs the customer-facing nurse profile.

3.4 Variant-snapshot serializer (library function for b8 — not an endpoint)

  • IVariantSnapshotSerializer (interface in Application/Contracts/, implementation in Application or Infrastructure) — a pure function string Serialize(variant + resolved options) that emits the canonical variant_snapshot_json: category id + name_fa/name_en, each (option_group label, option_value label), price, price_unit, session_count, display_name, and the variant id, as they are at serialize time. Booking (backend-phase-8) calls this to freeze the offering onto booking_requests.variant_snapshot_json so later variant edits/deactivation never mutate past bookings, disputes, or invoices. This phase ships and unit-tests the serializer; b8 persists its output. Do not add the snapshot column here.

3.5 REST endpoints

Controllers under Baya.Web.Api/Controllers/V1/ (sealed, BaseController, inject ISender, [controller]/[action] snake_case tokens, base.OperationResult(...), narrowest authorize policy, lists paginated). Routes shown logically; the snake_case transformer produces the real segments.

Verb & route Maps to Auth
POST /v1/admin/catalog/categories CreateServiceCategoryCommand admin
PUT /v1/admin/catalog/categories/{id} UpdateServiceCategoryCommand admin
PATCH /v1/admin/catalog/categories/{id}/active SetServiceCategoryActiveCommand admin
POST /v1/admin/catalog/option-groups CreateServiceOptionGroupCommand admin
PUT /v1/admin/catalog/option-groups/{id} UpdateServiceOptionGroupCommand admin
POST /v1/admin/catalog/option-values CreateServiceOptionValueCommand admin
PUT /v1/admin/catalog/option-values/{id} UpdateServiceOptionValueCommand admin
GET /v1/catalog/categories GetCatalogCategoriesQuery public
GET /v1/catalog/categories/{id}/option-groups GetCategoryOptionGroupsQuery public
POST /v1/nurse/variants CreateVariantCommand nurse (owner)
PUT /v1/nurse/variants/{id} UpdateVariantCommand nurse (owner)
PATCH /v1/nurse/variants/{id}/active SetVariantActiveCommand nurse (owner)
GET /v1/nurse/variants ListMyVariantsQuery nurse (owner)
GET /v1/nurse/variants/{id} GetVariantQuery owner / admin (full) · public (safe projection)

3.6 Out of scope (DEFERRED — build the seam/hook/serializer, not the feature)

  • nurse_search_index, the INurseSearch seam, the search query, and index fan-out/maintenance(DEFERRED → backend-phase-7). Keep the variant shape projection-friendly; b7 reads category/price/unit/is_active from it.
  • variant_snapshot_json persistence(DEFERRED → backend-phase-8). You ship the serializer (§3.4); b8 writes the column.
  • nurse_availability_slots / nurse_availability_exceptions — soft guidance, not money/safety path — (DEFERRED); note them, don't build.
  • Holiday/surge pricing rules engine; the lighter Companionship/daily-living tier as a pricing model; dynamic/tiered commission per category(DEFERRED), see product/business/03-service-catalog-and-pricing.md (c). Companionship ships only as a seeded category (data), not as a special pricing path.

4. Mocks & seams in this phase

None. Catalog and variant data are fully owned by Balinyaar's database — there is no third-party service to mock here. This phase introduces no cross-cutting seam.

  • Reuse ICacheService (from backend-phase-0) for the read-heavy public catalog reads (GetCatalogCategories, GetCategoryOptionGroups) with invalidation on the matching admin mutation. Do not redefine it.
  • The search-index writer seam (INurseSearch / ISearchIndexWriter) that variant writes will fan out through is introduced in backend-phase-7, not here. Do not pre-build it; leave the registry row to b7. (Listed in mocks-registry.md.)
  • IVariantSnapshotSerializer (§3.4) is an internal application contract, not an external-service seam — it does not go in the mock registry. It has a single real implementation.

Because this phase mocks nothing, there is no mocks-registry.md row to add — but you must still write the phase report (§8).

5. Critical rules you must not get wrong

  • The bookable unit is the variant, NOT the nurse. Search (b7), booking (b8), and every pricing calculation operate on nurse_service_variants — a nurse who has no active variant is not bookable. Do not anywhere treat "a nurse" as the priced/bookable entity; the customer pays for a specific variant.
  • price is IRR Rials as an integer (BIGINT) — no floats, anywhere. Never store, compute, or serialize price as a float/decimal-with-fraction; there is no Toman in the DB or the contract. The engagement total is price combined with price_unit and session_countdo NOT compute a total from price alone; the unit and session count are load-bearing and a downstream consumer (booking) derives the total from all three. Money crosses the wire as a string of digits per money-and-types.md.
  • One value per dimension per variant. Enforce UNIQUE(variant_id, option_group_id) on nurse_service_variant_options — a variant must never answer the same option group twice. Validate this in the handler and let the unique index be the authoritative backstop.
  • All required option groups must be answered. A CreateVariant/UpdateVariant that omits any applicable is_required group is a validation failure (clean OperationResult, not an exception). The applicable set = the category's own groups plus every cross-category (NULL service_category_id) group.
  • A NULL-category option group applies cross-category. service_option_groups.service_category_id = NULL means the dimension applies to every category. GetCategoryOptionGroups, the required-group check, and the duplicate guard must all include cross-category groups — silently dropping them is a defect.
  • Catalog must be seeded before any nurse can create a variant. The five seed categories ship in this phase's migration; a variant create against a missing/inactive category fails cleanly. Do not let variant creation succeed against a category that does not exist or is deactivated.
  • Duplicate identical listings are rejected. Two non-deleted variants for the same nurse, same category, and the identical answered option-set are not allowed — guarded by the filtered UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULL backstop plus a friendly pre-check conflict message. (A nurse may, of course, have many variants per category — just not two identical ones.)
  • Deactivate, never hard-delete. Variants (and categories/groups/values) soft-deactivate. A deactivated variant is unbookable and (via b7) drops out of search. Past bookings, snapshots, disputes, and invoices must never be mutated by a later catalog edit or deactivation — historical records survive via the snapshot (§3.4), which is the entire reason snapshotting exists.
  • EAV is load-bearing — categories/groups/values are DATA, not code. Do not hardcode categories, option groups, or option values as C# enums/constants. The only closed code enum in this area is the price_unit set (per_hour/per_session/per_half_day/per_day/per_24h). Admins must be able to add a new dimension as rows with no migration.
  • Every catalog row carries name_fa (primary) + name_en. Never persist or return a category/group/ value without both labels; the client picks by locale (it never derives a label from a code).
  • Tenancy & authority. Only the owning nurse may create/edit/deactivate their variants (check ICurrentUsernurse_profiles, not just a role); only admins may touch the catalog skeleton (categories/groups/values). A nurse editing another nurse's variant is a 403/NotFound, never a success.

6. Definition of Done

The shared definition-of-done.md, plus:

  • service_categories, service_option_groups, service_option_values, nurse_service_variants, nurse_service_variant_options exist via one additive migration with the §3.1 constraints: UNIQUE(variant_id, option_group_id); the nullable service_option_groups.service_category_id; the filtered duplicate-listing UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULL; price as BIGINT; soft-delete query filters. The five seed categories (name_fa+name_en) are present.
  • Admin CRUD (Create/Update/SetActive for categories; Create/Update for option groups & values) and the public GetCatalogCategories / GetCategoryOptionGroups queries are implemented as CQRS features with validators and the §3.5 endpoints, returning the standard OperationResult envelope. The public reads are cached via ICacheService and invalidated on the matching mutation.
  • Nurse CreateVariant / UpdateVariant / SetVariantActive / ListMyVariants / GetVariant are implemented with: required-group enforcement (incl. cross-category groups), one-value-per-dimension, the duplicate-listing guard (pre-check + DB backstop), auto-generated-but-editable display_name, and owner-tenancy. Variants deactivate, never hard-delete.
  • IVariantSnapshotSerializer is implemented and unit-tested (its JSON contains category labels, each option label, price, unit, session count) — ready for b8 to persist.
  • Tests prove: admin creates category + required group + values; a nurse builds a valid variant; a duplicate identical listing is rejected; missing a required group fails validation; ListMyVariants returns the nurse's active + inactive variants. (See §7.)
  • dotnet build Baya.sln zero new warnings; dotnet test Baya.sln green including this phase's tests.
  • The contract dev/contracts/domains/catalog.md is written and the swagger.json snapshot is refreshed; the server/CLAUDE.md Project map notes the new Features/Catalog (+ variants) area and the CatalogConfig configuration folder.

7. How to test (what a human can verify after this phase)

Run the API (dotnet run --project src/API/Baya.Web.Api/...) against a reachable SQL Server; use Swagger or curl. The expected results below become the "what can be tested" section of your report.

  1. Catalog is seeded. GET /v1/catalog/categories200, lists the five seed categories with name_fa+name_en, ordered by sort_order, active only.
  2. Admin builds a dimension. As admin, POST /v1/admin/catalog/option-groups with service_category_id = Elderly Care, name_fa: "نوع شیفت", name_en: "Shift type", is_required: true200. Then POST /v1/admin/catalog/option-values twice (e.g. روزانه / Daytime, شبانه‌روزی / Live-in) → both 200. GET /v1/catalog/categories/{elderly_id}/option-groups → returns the new required group plus any cross-category groups, each with its values.
  3. Nurse builds a valid variant. As a nurse, POST /v1/nurse/variants with the Elderly category, the required shift-type value = شبانه‌روزی, price: "8000000" (IRR string), price_unit: per_24h200, variant created is_active: true, with an auto-generated display_name containing the category + option labels.
  4. Duplicate identical listing is rejected. Repeat the exact same POST /v1/nurse/variants (same category + same option-set) → a clean conflict OperationResult failure (not a 500, not a raw DB exception).
  5. Missing a required group fails validation. POST /v1/nurse/variants for Elderly without the required shift-type value → a 400/validation OperationResult failure naming the missing required group.
  6. One value per dimension. Attempt to send two values for the same option group in one create → rejected (handler + the UNIQUE(variant_id, option_group_id) backstop).
  7. List my variants (active + inactive). GET /v1/nurse/variants → both the active variant and (after PATCH /v1/nurse/variants/{id}/active → inactive) the deactivated one appear, visually/dataly distinct; the deactivated one is flagged unbookable. The variant row is never hard-deleted.
  8. Tenancy. As a different nurse, PUT /v1/nurse/variants/{id} on the first nurse's variant → 403/NotFound, never a success.
  9. Snapshot serializer (unit test). A unit test serializes a variant + its options and asserts the JSON carries the category labels, each option label, price, price_unit, and session_count.

8. Hand off & document (close the phase)

  • Docs to update (same change): server/CLAUDE.md Project map — add the Features/Catalog (categories/option-groups/option-values) and the nurse Variants feature area, the five new tables + the Persistence/Configuration/CatalogConfig/ folder, and the IVariantSnapshotSerializer contract (and that it is consumed by b8). If you established a reusable option-set hashing helper, note it in server/CONVENTIONS.md. If you discovered/decided any business rule not already in the product docs, reflect it in product/business/03-service-catalog-and-pricing.md or product/data-model/03-services-and-pricing.md (no invented rules — record decisions, and regenerate the HTML view per product/CLAUDE.md if you touched Markdown).
  • Contract to write: publish dev/contracts/domains/catalog.md (the §3.5 routes; request/response shapes for categories, option groups, option values, and variants; the price_unit enum; the name_fa/ name_en pairing; the IRR-string money format; the required-group/duplicate-listing failure cases and status codes — 409 on a duplicate listing, 400 on a missing required group; examples) per ../../contracts/conventions/api-conventions.md and money-and-types.md, starting from the domain contract template. Refresh the swagger.json snapshot per ../../contracts/openapi/README.md so frontend-phase-4-b5 can derive its types (it does not guess shapes).
  • Handoff & report: write shared-working-context/backend/handoff/after-backend-phase-5.md (the catalog is seeded and admin-CRUDable; the public catalog browse endpoints are live; nurses can build/edit/deactivate variants; the IVariantSnapshotSerializer is ready for b8; what b7 must read from the variant for the search index; what f4-b5 can now build — category grid + service builder; the duplicate-listing, required-group, and one-value-per-dimension rules the frontend must respect). Append your phase summary to shared-working-context/backend/STATUS.md, and write reports/backend-phase-5-report.md (what was built, what is now testable and exactly how — the §7 steps — that nothing is mocked in this phase, the contract produced, and follow-ups for b7 (index projection) and b8 (snapshot persistence)). This phase adds no mocks-registry.md row (it mocks nothing) — state that explicitly in the report so the next agent doesn't go looking.
  • Memory: save a project-type memory note for the non-obvious decisions here — the variant is the bookable unit principle, the EAV / NULL-category cross-category rule, the option_set_hash + filtered-unique duplicate-listing strategy, and the price + price_unit + session_count (never price alone) total rule — with a one-line MEMORY.md pointer.