Files
baya-monorepo/dev/shared-working-context/reports/backend-phase-5-report.md
T
hamid f77a23cb25 backend phase 5: service catalog & nurse pricing variants
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>
2026-07-02 22:21:53 +03:30

7.8 KiB
Raw Blame History

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 (nullable ServiceCategoryId = cross-category), ServiceOptionValue, NurseServiceVariant (Price BIGINT IRR, PriceUnit, SessionCount?, DisplayName, OptionSetHash), NurseServiceVariantOption, and the closed PriceUnits set (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); filtered UNIQUE(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 15) via HasData.
  • Admin catalog CQRS (Features/Catalog/): CreateServiceCategory/UpdateServiceCategory/ SetServiceCategoryActive, CreateServiceOptionGroup/UpdateServiceOptionGroup, CreateServiceOptionValue/UpdateServiceOptionValue, and the public cached GetCatalogCategories (paginated) / GetCategoryOptionGroups (applicable = own + cross-category). Every mutation invalidates the CatalogCache generation 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).
  • OptionSetHash helper (deterministic, order-independent SHA-256) + IVariantSnapshotSerializer (pure, singleton; canonical variant_snapshot_json for 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.

  1. Catalog seededGET /api/v1/catalog/categories200, five categories, nameFa+nameEn, ordered by sortOrder, active only.
  2. 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_value twice → 200; GET /api/v1/catalog/option_groups?category_id=1 → the required group plus any cross-category groups, each with its values.
  3. Nurse builds a valid variantPOST /api/v1/nurse_variants/create { serviceCategoryId: 1, options: [{ optionGroupId, optionValueId }], price: "8000000", priceUnit: "per_24h" }200, isActive: true, auto displayName = category + value labels.
  4. Duplicate identical listing — repeat the exact create → clean 409 (not a 500).
  5. Missing required dimension — create with options: []400 naming the missing group.
  6. One value per dimension — two values for the same group in one create → rejected (handler + the UNIQUE(variant_id, option_group_id) backstop).
  7. List active + inactiveGET /api/v1/nurse_variants/list; then POST /api/v1/nurse_variants/set_active/{id} { isActive: false } → the deactivated variant still appears, flagged isActive: false and unbookable; the row is never hard-deleted.
  8. Tenancy — a different nurse POST /api/v1/nurse_variants/update/{id} on the first nurse's variant → 404 (existence not leaked).
  9. Snapshot serializer — unit-tested: the JSON carries the category labels, each option label, price (as a digit string), priceUnit, and sessionCount.

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_unit enum, IRR-string money, 409/400 failure cases, examples). swagger.v1.json refreshed (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's nurse_service_areas (b4). The maintenance trigger points are CreateVariantCommand / SetVariantActiveCommand (and future option/price edits).
  • b8 (booking) — owns booking_requests.variant_snapshot_json; calls IVariantSnapshotSerializer at 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_value does not re-parent a value to another group (would silently change the meaning of variants that already answered with it).
  • price crosses the wire as a string of digits (money-and-types.md); the DTO/command use string.
  • The OptionSetHash + filtered-unique duplicate-listing strategy is noted as a reusable pattern in server/CONVENTIONS.md §6. No new business rules were discovered — the product docs (business/03, data-model/03) already matched; left unchanged.