Files
baya-monorepo/dev/shared-working-context/backend/handoff/after-backend-phase-5.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

83 lines
6.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`](../../../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) with `nameFa`+`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 with `isRequired` and its active values. The nurse then `POST api/v1/nurse_variants/create`
`{ serviceCategoryId, options: [{ optionGroupId, optionValueId }], price, priceUnit, sessionCount?, displayName? }`.
Manage with `update/{id}`, `set_active/{id}`, `list`, `get/{id}`. Requires a nurse profile first
(b3 `nurse_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.
- **`price` is a string of IRR-Rial digits** (e.g. `"8000000"`) — integer money, no floats, no Toman. The
**total is `price` + `priceUnit` + `sessionCount`**; never compute it from `price` alone. Parse with a
BigInt-safe helper, format for display, map `priceUnit` to 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.
- **`displayName` auto-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` (flagged
`isActive:false`) but is unbookable and 404s on the public `get`.
- **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_role`** still 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 15. 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_json` persistence** → **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.