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>
7.8 KiB
Backend Phase 5 — Service catalog & nurse pricing variants — report
Status: complete · build clean (0 new code warnings) · dotnet test Baya.sln green (122 pass:
+21 over b4's 101 — 10 new handler/serializer unit tests, +11 API integration & adversarially reviewed).
Nothing is mocked in this phase.
What was built
The two-tier service model the whole marketplace is priced/searched on — one additive migration
(20260702132758_ServiceCatalogAndNurseVariants, new catalog schema), five tables, 14 endpoints across
3 controllers, plus the b8 snapshot serializer.
- Entities (
Domain/Entities/Catalog/):ServiceCategory,ServiceOptionGroup(nullableServiceCategoryId= cross-category),ServiceOptionValue,NurseServiceVariant(PriceBIGINT IRR,PriceUnit,SessionCount?,DisplayName,OptionSetHash),NurseServiceVariantOption, and the closedPriceUnitsset (per_hour/per_session/per_half_day/per_day/per_24h). - EF configs + seed (
Persistence/Configuration/CatalogConfig/): soft-delete query filters;UNIQUE(variant_id, option_group_id); filteredUNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULL;(is_active, sort_order)/(service_category_id, sort_order)/(option_group_id, sort_order)/(nurse_id, is_active)/(service_category_id)indexes. Five seed categories (nameFa+nameEn, ids 1–5) viaHasData. - Admin catalog CQRS (
Features/Catalog/):CreateServiceCategory/UpdateServiceCategory/SetServiceCategoryActive,CreateServiceOptionGroup/UpdateServiceOptionGroup,CreateServiceOptionValue/UpdateServiceOptionValue, and the public cachedGetCatalogCategories(paginated) /GetCategoryOptionGroups(applicable = own + cross-category). Every mutation invalidates theCatalogCachegeneration token. - Nurse variant CQRS (
Features/Variants/):CreateVariant(required-group enforcement incl. cross-category, one-value-per-dimension, value-in-group, duplicate-listing pre-check + auto display-name),UpdateVariant(price/unit/session/display; option-set immutable),SetVariantActive(deactivate, never delete),ListMyVariants(active + inactive),GetVariant(owner/admin full · public active-only). OptionSetHashhelper (deterministic, order-independent SHA-256) +IVariantSnapshotSerializer(pure, singleton; canonicalvariant_snapshot_jsonfor b8).- Controllers (
Controllers/V1/):AdminCatalogController(dynamic-permission),CatalogController(public),NurseVariantsController([Authorize];get/{id}is[AllowAnonymous]).
What is now testable — and exactly how (the phase §7 steps)
Run the API (dotnet run --project src/API/Baya.Web.Api/...) against a reachable SQL Server; use Swagger/curl.
- Catalog seeded —
GET /api/v1/catalog/categories→200, five categories,nameFa+nameEn, ordered bysortOrder, active only. - Admin builds a dimension — as admin,
POST /api/v1/admin_catalog/create_option_group{ serviceCategoryId: 1, nameFa: "نوع شیفت", nameEn: "Shift type", isRequired: true, sortOrder: 1 }→200;POST /api/v1/admin_catalog/create_option_valuetwice →200;GET /api/v1/catalog/option_groups?category_id=1→ the required group plus any cross-category groups, each with its values. - Nurse builds a valid variant —
POST /api/v1/nurse_variants/create{ serviceCategoryId: 1, options: [{ optionGroupId, optionValueId }], price: "8000000", priceUnit: "per_24h" }→200,isActive: true, autodisplayName= category + value labels. - Duplicate identical listing — repeat the exact create → clean
409(not a500). - Missing required dimension — create with
options: []→400naming the missing group. - One value per dimension — two values for the same group in one create → rejected (handler + the
UNIQUE(variant_id, option_group_id)backstop). - List active + inactive —
GET /api/v1/nurse_variants/list; thenPOST /api/v1/nurse_variants/set_active/{id}{ isActive: false }→ the deactivated variant still appears, flaggedisActive: falseand unbookable; the row is never hard-deleted. - Tenancy — a different nurse
POST /api/v1/nurse_variants/update/{id}on the first nurse's variant →404(existence not leaked). - Snapshot serializer — unit-tested: the JSON carries the category labels, each option label,
price(as a digit string),priceUnit, andsessionCount.
Automated coverage: Baya.Test.Foundation/Catalog/ (CreateVariant valid/missing-required/duplicate/
one-value-per-dimension/value-not-in-group/inactive-category/non-nurse/override; admin category cache
invalidation + parent checks + cross-category null group; serializer) and Baya.Test.Api/
(CatalogPublicApiTests, NurseVariantsApiTests — full lifecycle incl. 409/400/tenancy-404/public-get/401).
Adversarial review
A 4-dimension review (tenancy/authorization, EAV/cross-category/required-group, money integrity, EF-translation/
caching/soft-delete) with independent per-finding verification ran over the diff (159 tool-uses, ~382k tokens):
0 confirmed findings. Spot-checked by hand: cross-nurse get/{id} returns the public (never full) view;
the duplicate hash includes cross-category selections; missing a required cross-category group → 400.
Contracts produced / consumed
- Produced:
dev/contracts/domains/catalog.md(routes, shapes,price_unitenum, IRR-string money,409/400failure cases, examples).swagger.v1.jsonrefreshed (71 paths total; +14 catalog/variant paths) — verified the API boots and applies the migration on a real SQL Server. - Consumed:
nurse_profiles(b3),ICacheService/CQRS/OperationResult/BaseController(b0), admin dynamic-permission policy (b1/b2). No geography coupling (b7 joins the two later).
Mocks
None. This phase introduces no cross-cutting seam and adds no row to
mocks-registry.md — stated here so the next agent doesn't go looking.
IVariantSnapshotSerializer is an internal application contract with a single real implementation (not a
mock seam). ICacheService is reused (already 🟡 from b0), not redefined.
Follow-ups for later phases
- b7 (search & matching) — owns
nurse_search_index,INurseSearch, the search query, and index fan-out. Reads per active variant:service_category_id,price,price_unit,is_active,nurse_id, fanned across the nurse'snurse_service_areas(b4). The maintenance trigger points areCreateVariantCommand/SetVariantActiveCommand(and future option/price edits). - b8 (booking) — owns
booking_requests.variant_snapshot_json; callsIVariantSnapshotSerializerat booking time. The serializer is shipped and unit-tested here. - Deferred (not built):
nurse_availability_slots/_exceptions(soft guidance); holiday/surge pricing; a Companionship tier pricing model (ships only as a seeded category); tiered per-category commission.
Notable decisions (recorded in product/eng docs, not invented)
- Routes are action-style POST with camelCase bodies (matching b3/b4), not the PUT/PATCH the phase table sketched — documented in the contract's routing note.
update_option_valuedoes not re-parent a value to another group (would silently change the meaning of variants that already answered with it).pricecrosses the wire as a string of digits (money-and-types.md); the DTO/command usestring.- The
OptionSetHash+ filtered-unique duplicate-listing strategy is noted as a reusable pattern inserver/CONVENTIONS.md§6. No new business rules were discovered — the product docs (business/03,data-model/03) already matched; left unchanged.