6.4 KiB
After backend-phase-5 — the service catalog & nurse pricing variants are live
The two-tier service model the whole marketplace is priced and searched on now exists. Admins curate a
catalog skeleton (categories → configurable option groups → option values) that ships as data, not
migrations, and each nurse turns that skeleton into variants — the atomic bookable unit: a category +
one chosen value per dimension, at the nurse's own price and price unit. Contract:
dev/contracts/domains/catalog.md; machine schema:
dev/contracts/openapi/swagger.v1.json (refreshed for b5).
What the frontend (f4-b5) can now build
- Category grid / browse —
GET api/v1/catalog/categories(active, ordered, paginated, cached). Five categories are seeded (Elderly, Post-Surgery, Infant, Chronic, Companionship) withnameFa+nameEn. - The nurse service-builder — for a chosen category,
GET api/v1/catalog/option_groups?category_id=returns the applicable dimensions (the category's own groups plus every cross-category group), each withisRequiredand its active values. The nurse thenPOST api/v1/nurse_variants/create{ serviceCategoryId, options: [{ optionGroupId, optionValueId }], price, priceUnit, sessionCount?, displayName? }. Manage withupdate/{id},set_active/{id},list,get/{id}. Requires a nurse profile first (b3nurse_profiles/upsert). - Admin catalog console —
admin_catalog/create_category|update_category/{id}|set_category_active/{id},create_option_group|update_option_group/{id},create_option_value|update_option_value/{id}(admin token / dynamic-permission). - Public variant view —
GET api/v1/nurse_variants/get/{id}returns the public (active-only) projection for anyone, backing a nurse-profile offerings list.
Rules baked into the API (don't fight them client-side)
- The bookable unit is the variant, not the nurse. Price/book against a specific variant.
priceis a string of IRR-Rial digits (e.g."8000000") — integer money, no floats, no Toman. The total isprice+priceUnit+sessionCount; never compute it frompricealone. Parse with a BigInt-safe helper, format for display, mappriceUnitto an i18n label (never off the code).- A NULL-category option group is cross-category — it applies to every category. Render the cross- category groups in the builder for every category; the required ones must be answered.
- All required dimensions must be answered, one value per dimension, a value must belong to its group. A missing required dimension → 400 (names it); a duplicate identical listing → 409; surface both cleanly.
displayNameauto-generates (category + chosen value labels) — show it, let the nurse override it.- Deactivate, never delete. A deactivated variant stays in the nurse's own
list(flaggedisActive:false) but is unbookable and 404s on the publicget. - Every catalog row has
nameFa(primary) +nameEn— pick by locale, never label off a code. - Tenancy — variants are strictly owner-scoped; another nurse's id on write → 404 (existence not leaked). Catalog skeleton writes are admin-only.
- Refresh after
select_rolestill applies (nurse scoping reads the role claim in the token). - Routes are action-style (
admin_catalog/create_category,nurse_variants/create, …), POST for mutations, camelCase bodies — see the contract for the full list.
What b7 (search & matching) must read from the variant
The variant is a clean, projection-friendly source. b7 owns the denormalized nurse_search_index and the
INurseSearch seam (not built here). Its fan-out reads, per active variant: service_category_id,
price, price_unit, is_active, nurse_id — joined to the nurse's nurse_service_areas (b4) to emit one
index row per covered city/district, and to nurse_profiles for is_verified/accepting/gender/rating. The
single write trigger points to maintain that index are CreateVariantCommand, SetVariantActiveCommand
(and later option/price edits) — hook the index maintenance there.
What b8 (booking) consumes
IVariantSnapshotSerializer (Application/Contracts/Common, single real impl in Application/Common) is
ready: string Serialize(VariantSnapshot) emits the canonical variant_snapshot_json (category id + labels,
each (group label, value label), price as a digit string, price_unit, session_count, display_name,
variant_id). b8 owns the booking_requests.variant_snapshot_json column and calls the serializer at
booking time so later variant edits/deactivation never mutate past bookings. The snapshot column is not
added here.
Schema / migration
Migration 20260702132758_ServiceCatalogAndNurseVariants (applies on startup), new catalog schema:
ServiceCategories, ServiceOptionGroups (nullable ServiceCategoryId = cross-category),
ServiceOptionValues, NurseServiceVariants (Price BIGINT, PriceUnit code, SessionCount?,
DisplayName, OptionSetHash), NurseServiceVariantOptions. Constraints: UNIQUE(VariantId, OptionGroupId)
(one value per dimension); filtered UNIQUE(NurseId, ServiceCategoryId, OptionSetHash) WHERE DeletedAt IS NULL (duplicate-listing backstop); soft-delete query filters. Seed: five categories (nameFa+nameEn),
ids 1–5. Option groups/values are admin-authored data (not seeded).
Deferred to later phases (do not build against these yet)
nurse_search_index,INurseSearch, the search query & index fan-out → b7 (variant writes are the clean trigger point; the variant shape is projection-ready).variant_snapshot_jsonpersistence → b8 (the serializer is shipped and unit-tested here).nurse_availability_slots/nurse_availability_exceptions→ deferred (soft scheduling guidance, not on the money/safety path) — not built.- Holiday/surge pricing, a Companionship tier pricing model, tiered per-category commission → deferred. Companionship ships only as a seeded category (data), not a special pricing path.
What's mocked
Nothing. Catalog and variant data are fully owned by Balinyaar's DB — this phase introduces no
cross-cutting seam and adds no row to reports/mocks-registry.md. It reuses ICacheService (b0) for the
public catalog reads with invalidate-on-mutation.