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_variantsthat 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 profiles — backend-phase-3 built
nurse_profiles(1:1 →users, carryingis_verified,is_accepting_bookings, and the denormalizedaverage_rating/total_reviews/total_completed_bookingsaggregates),customer_profiles,patients, andnurse_bank_accounts. backend-phase-2 established phone-OTP auth, sessions, andusers.gender(male/female). Variants FK tonurse_profiles— read that entity; do not re-model it. Tenancy ("only the owning nurse edits their variants") keys offICurrentUser→nurse_profiles. - Config, admin auth & the migration baseline — backend-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-appnotificationswrite, and thesupport_alertsraise API. Your migration is additive on top of b1's baseline. Admin endpoints use the admin policy established by b1/b2 — reuse it. - Geography — backend-phase-4 built
provinces/cities/districts(+ seed) andnurse_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 seams — backend-phase-0 introduced
ICacheService,IDateTimeProvider,IFieldEncryptor,IObjectStorage, andINotificationDispatcher, plus the REST surface (BaseController, snake_case routing, rate limiting), the CQRS pipeline (ISender/ICommand/IQuery,ValidateCommandBehavior,OperationResult<T>), and the audit-field interceptor. ReuseICacheServicefor the read-heavy public catalog reads; do not introduce new seams.
The denormalized
nurse_search_index, theINurseSearchsearch 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_jsonthat 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_exceptionsare soft scheduling guidance only — not on the money or safety path. They are (DEFERRED) for MVP; seeproduct/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.mdand../_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-editabledisplay_name; deactivate, never delete; and that catalog is snapshotted onto the booking. Read (b) Iran-specific — whyper_24h/per_dayare 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), theUNIQUE(variant_id, option_group_id)"one value per dimension" rule, the NULLservice_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.md— price is IRR Rials as an integer (BIGINT), no floats, and money crosses the wire as a string of digits; theprice_unitenum is a stable string code;name_fa/name_enreference data returns both. - Code to mirror (existing patterns): an existing feature folder under
Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/(requestrecord+internal sealedhandler +OperationResult, validator picked up byValidateCommandBehavior), anIEntityTypeConfiguration<T>underPersistence/Configuration/<Area>Config/, asealedcontroller underBaya.Web.Api/Controllers/V1/(BaseController, injectISender,[controller]/[action]snake_case tokens,base.OperationResult(...)), the b1 reference-data seed pattern (how seededname_fa/name_enrows were inserted in the baseline migration), and how reads useAsNoTracking()+.Select()projection- pagination +
ICacheService.
- pagination +
- Contract conventions:
../../contracts/conventions/api-conventions.md(envelope, snake_case routes, status codes, mandatory list pagination, localisation ofname_fa/name_en). - Prior handoffs:
dev/shared-working-context/backend/handoff/after-backend-phase-3.md(thenurse_profilesshape + 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).
- Columns:
service_option_groups— admin-managed configurable dimensions (e.g. نوع شیفت / shift type, تعداد بیمار / patient count).- Columns:
id(BIGINT PK),service_category_id(BIGINT FK →service_categories, NULLABLE — NULL = 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.
- Columns:
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.
- Columns:
nurse_service_variants— the 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 setper_hour|per_session|per_half_day|per_day|per_24h),session_count(INT, nullable — number of sessions/units the engagement spans; relevant forper_sessionand 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 onservice_category_id; the duplicate-listing guard (§3.3) on(nurse_id, service_category_id, option-set); soft-delete query filter (!IsDeleted). price_unitis 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).
- Columns:
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.
- Columns:
Do not add
nurse_search_indexhere. It is b7's denormalized read model. Do not addvariant_snapshot_json— it lives onbooking_requestsand is owned by b8. Do not addnurse_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/) — setname_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/) — setservice_category_id(nullable — NULL marks the group cross-category),name_fa/name_en,is_required,sort_order.CreateServiceOptionValueCommand/UpdateServiceOptionValueCommand(Commands/CreateServiceOptionValue/,.../UpdateServiceOptionValue/) — setoption_group_id,name_fa/name_en,sort_order,is_active.GetCatalogCategoriesQuery(Queries/GetCatalogCategories/) — public, paginated,is_activecategories ordered bysort_order, returningname_fa/name_en.AsNoTracking()+.Select(); cache throughICacheServicewith 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 bysort_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 ICurrentUser → nurse_profiles.
CreateVariantCommand(Commands/CreateVariant/) — the nurse picks aservice_category_id, supplies the chosenoption_value_idfor each required group (and any optional groups they answer), setsprice(IRR BIGINT) +price_unit(one of the five) + optionalsession_count, and an optionaldisplay_nameoverride. The handler, in one transaction:- Validate the category exists and is active.
- Resolve applicable groups (the category's groups + cross-category groups) and assert every
is_requiredgroup 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. - 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 conflictOperationResult, never a raw DB exception. - Auto-generate
display_namefrom the chosen option labels (e.g. category + " · " + value labels) when the nurse did not override it. - Insert the variant + its
nurse_service_variant_optionsrows;CommitAsynconce.
- FluentValidation:
price > 0,price_unitin the closed set,session_countnull 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_hashcolumn onnurse_service_variants(a stable hash of the sorted(option_group_id, option_value_id)pairs, computed in the handler) and put a filteredUNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULLon 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 editsprice,price_unit,session_count, anddisplay_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, recomputeoption_set_hashand 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. Editingdisplay_namemay piggyback asEditVariantDisplayNameCommandor be folded intoUpdateVariantCommand— 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 inApplication/Contracts/, implementation in Application or Infrastructure) — a pure functionstring Serialize(variant + resolved options)that emits the canonicalvariant_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 ontobooking_requests.variant_snapshot_jsonso 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, theINurseSearchseam, the search query, and index fan-out/maintenance — (DEFERRED → backend-phase-7). Keep the variant shape projection-friendly; b7 reads category/price/unit/is_activefrom it.variant_snapshot_jsonpersistence — (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 inmocks-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. priceis 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 ispricecombined withprice_unitandsession_count— do NOT compute a total frompricealone; 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 permoney-and-types.md.- One value per dimension per variant. Enforce
UNIQUE(variant_id, option_group_id)onnurse_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/UpdateVariantthat omits any applicableis_requiredgroup is a validation failure (cleanOperationResult, not an exception). The applicable set = the category's own groups plus every cross-category (NULLservice_category_id) group. - A NULL-category option group applies cross-category.
service_option_groups.service_category_id = NULLmeans 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 NULLbackstop 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_unitset (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
ICurrentUser→nurse_profiles, not just a role); only admins may touch the catalog skeleton (categories/groups/values). A nurse editing another nurse's variant is a403/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_optionsexist via one additive migration with the §3.1 constraints:UNIQUE(variant_id, option_group_id); the nullableservice_option_groups.service_category_id; the filtered duplicate-listingUNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULL;priceasBIGINT; soft-delete query filters. The five seed categories (name_fa+name_en) are present.- Admin CRUD (
Create/Update/SetActivefor categories;Create/Updatefor option groups & values) and the publicGetCatalogCategories/GetCategoryOptionGroupsqueries are implemented as CQRS features with validators and the §3.5 endpoints, returning the standardOperationResultenvelope. The public reads are cached viaICacheServiceand invalidated on the matching mutation. - Nurse
CreateVariant/UpdateVariant/SetVariantActive/ListMyVariants/GetVariantare implemented with: required-group enforcement (incl. cross-category groups), one-value-per-dimension, the duplicate-listing guard (pre-check + DB backstop), auto-generated-but-editabledisplay_name, and owner-tenancy. Variants deactivate, never hard-delete. IVariantSnapshotSerializeris 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;
ListMyVariantsreturns the nurse's active + inactive variants. (See §7.) dotnet build Baya.slnzero new warnings;dotnet test Baya.slngreen including this phase's tests.- The contract
dev/contracts/domains/catalog.mdis written and theswagger.jsonsnapshot is refreshed; theserver/CLAUDE.mdProject map notes the newFeatures/Catalog(+ variants) area and theCatalogConfigconfiguration 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.
- Catalog is seeded.
GET /v1/catalog/categories→200, lists the five seed categories withname_fa+name_en, ordered bysort_order, active only. - Admin builds a dimension. As admin,
POST /v1/admin/catalog/option-groupswithservice_category_id= Elderly Care,name_fa: "نوع شیفت",name_en: "Shift type",is_required: true→200. ThenPOST /v1/admin/catalog/option-valuestwice (e.g.روزانه / Daytime,شبانهروزی / Live-in) → both200.GET /v1/catalog/categories/{elderly_id}/option-groups→ returns the new required group plus any cross-category groups, each with its values. - Nurse builds a valid variant. As a nurse,
POST /v1/nurse/variantswith the Elderly category, the required shift-type value = شبانهروزی,price: "8000000"(IRR string),price_unit: per_24h→200, variant createdis_active: true, with an auto-generateddisplay_namecontaining the category + option labels. - Duplicate identical listing is rejected. Repeat the exact same
POST /v1/nurse/variants(same category + same option-set) → a clean conflictOperationResultfailure (not a 500, not a raw DB exception). - Missing a required group fails validation.
POST /v1/nurse/variantsfor Elderly without the required shift-type value → a400/validationOperationResultfailure naming the missing required group. - 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). - List my variants (active + inactive).
GET /v1/nurse/variants→ both the active variant and (afterPATCH /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. - Tenancy. As a different nurse,
PUT /v1/nurse/variants/{id}on the first nurse's variant →403/NotFound, never a success. - 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, andsession_count.
8. Hand off & document (close the phase)
- Docs to update (same change):
server/CLAUDE.mdProject map — add theFeatures/Catalog(categories/option-groups/option-values) and the nurseVariantsfeature area, the five new tables + thePersistence/Configuration/CatalogConfig/folder, and theIVariantSnapshotSerializercontract (and that it is consumed by b8). If you established a reusable option-set hashing helper, note it inserver/CONVENTIONS.md. If you discovered/decided any business rule not already in the product docs, reflect it inproduct/business/03-service-catalog-and-pricing.mdorproduct/data-model/03-services-and-pricing.md(no invented rules — record decisions, and regenerate the HTML view perproduct/CLAUDE.mdif 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; theprice_unitenum; thename_fa/name_enpairing; the IRR-string money format; the required-group/duplicate-listing failure cases and status codes —409on a duplicate listing,400on a missing required group; examples) per../../contracts/conventions/api-conventions.mdandmoney-and-types.md, starting from the domain contract template. Refresh theswagger.jsonsnapshot per../../contracts/openapi/README.mdso 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; theIVariantSnapshotSerializeris 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 toshared-working-context/backend/STATUS.md, and writereports/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 nomocks-registry.mdrow (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, theoption_set_hash+ filtered-unique duplicate-listing strategy, and the price + price_unit + session_count (never price alone) total rule — with a one-lineMEMORY.mdpointer.