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>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
# 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 1–5) 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 seeded** — `GET /api/v1/catalog/categories` → `200`, 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 variant** — `POST /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 + inactive** — `GET /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`](../../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`](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.
|
||||
Reference in New Issue
Block a user