add build development phases
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
# Backend Phase 5 — Service catalog & nurse pricing variants
|
||||
|
||||
> **Mission:** stand up the two-tier service model that the entire marketplace is priced and searched on.
|
||||
> First the **admin catalog skeleton** — top-level care **categories** plus EAV-style configurable
|
||||
> **option groups** and **option values** so new pricing dimensions ship as *data*, not migrations. Then the
|
||||
> **nurse pricing layer** — the `nurse_service_variants` that are the **atomic bookable unit** of the whole
|
||||
> platform: a nurse + a category + one chosen value per required dimension, at the nurse's own price and
|
||||
> price unit. Transparent, upfront, nurse-set pricing is a deliberate differentiator versus the opaque
|
||||
> "توافقی / negotiable" incumbents — and after this phase, search (b7), booking (b8), and every downstream
|
||||
> money calculation operate on a variant, never on a nurse.
|
||||
>
|
||||
> **Track:** backend · **Depends on:** [backend-phase-3](backend-phase-3.md) (`nurse_profiles`, identity/roles), [backend-phase-1](backend-phase-1.md) (marketplace migration baseline, seed/config, admin auth) · **Unlocks:** search & matching ([backend-phase-7](backend-phase-7.md)), booking requests & lifecycle ([backend-phase-8](backend-phase-8.md)), and the catalog browse + service-builder UI ([frontend-phase-4-b5](../frontend/frontend-phase-4-b5.md))
|
||||
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
|
||||
|
||||
---
|
||||
|
||||
## 1. Context — where this sits
|
||||
|
||||
This is the **catalog & pricing** phase. Identity exists (b2/b3): there are `users` with a `role`
|
||||
(`admin`/`nurse`/`customer`) and a `gender`, and every nurse has a `nurse_profiles` row. Geography exists
|
||||
(b4): `provinces`/`cities`/`districts` are seeded and `nurse_service_areas` declares where a nurse will
|
||||
travel. Config & reference (b1) gave us the **first marketplace migration baseline**, the typed cached
|
||||
config accessor, admin auth, and the `support_alerts`/`notifications` plumbing. None of that defines *what a
|
||||
nurse sells or for how much* — that is this phase.
|
||||
|
||||
The product models catalog in **three admin layers** (category → option group → option value) and **two
|
||||
nurse layers** (variant → variant option). The admin layers are an intentionally **EAV-style configurable
|
||||
structure**: an admin can introduce a new pricing dimension ("live-in", "number of patients") as rows, with
|
||||
**no schema migration**. The nurse layers turn that skeleton into priced, bookable offerings. The output of
|
||||
this phase is the thing the customer actually pays for: a **variant**.
|
||||
|
||||
**What already exists (do not rebuild):**
|
||||
|
||||
- **Identity, roles & nurse profiles** — [backend-phase-3](backend-phase-3.md) built `nurse_profiles`
|
||||
(1:1 → `users`, carrying `is_verified`, `is_accepting_bookings`, and the denormalized
|
||||
`average_rating`/`total_reviews`/`total_completed_bookings` aggregates), `customer_profiles`, `patients`,
|
||||
and `nurse_bank_accounts`. [backend-phase-2](backend-phase-2.md) established phone-OTP auth, sessions, and
|
||||
`users.gender` (`male`/`female`). **Variants FK to `nurse_profiles`** — read that entity; do not re-model
|
||||
it. Tenancy ("only the owning nurse edits their variants") keys off `ICurrentUser` → `nurse_profiles`.
|
||||
- **Config, admin auth & the migration baseline** — [backend-phase-1](backend-phase-1.md) created the **first
|
||||
marketplace EF Core migration** (the baseline every later phase extends additively), `platform_configs`
|
||||
(typed cached accessor), `audit_logs` (written by the SaveChanges interceptor), the in-app `notifications`
|
||||
write, and the `support_alerts` raise API. Your migration is **additive** on top of b1's baseline. Admin
|
||||
endpoints use the admin policy established by b1/b2 — reuse it.
|
||||
- **Geography** — [backend-phase-4](backend-phase-4.md) built `provinces`/`cities`/`districts` (+ seed) and
|
||||
`nurse_service_areas` (`district_id = NULL` ⇒ whole city; `UNIQUE(nurse_id, city_id, district_id)`).
|
||||
Catalog itself does **not** touch geography — but b7 fans a variant out across its nurse's service areas
|
||||
into the search index, so keep the variant shape clean for that projection.
|
||||
- **Cross-cutting seams** — [backend-phase-0](backend-phase-0.md) introduced `ICacheService`,
|
||||
`IDateTimeProvider`, `IFieldEncryptor`, `IObjectStorage`, and `INotificationDispatcher`, plus the REST
|
||||
surface (`BaseController`, snake_case routing, rate limiting), the CQRS pipeline
|
||||
(`ISender`/`ICommand`/`IQuery`, `ValidateCommandBehavior`, `OperationResult<T>`), and the audit-field
|
||||
interceptor. **Reuse `ICacheService`** for the read-heavy public catalog reads; do not introduce new seams.
|
||||
|
||||
> The **denormalized `nurse_search_index`**, the `INurseSearch` search seam, and the index-maintenance
|
||||
> hooks that fan a variant out per covered area are owned by **[backend-phase-7](backend-phase-7.md)**, *not*
|
||||
> this phase. You build the variant as a clean, projectable source; b7 reads it. Do **not** create the search
|
||||
> index or a search query here. **(DEFERRED → b7.)**
|
||||
>
|
||||
> The **`variant_snapshot_json`** that freezes a variant onto a booking at booking time is owned by the
|
||||
> **Booking area ([backend-phase-8](backend-phase-8.md))**. This phase ships a **variant-snapshot serializer**
|
||||
> (a pure library function — §3.4) that b8 *consumes*; it is not an endpoint here. **(snapshot persistence
|
||||
> DEFERRED → b8.)**
|
||||
>
|
||||
> **`nurse_availability_slots` / `nurse_availability_exceptions`** are **soft scheduling guidance only** — not
|
||||
> on the money or safety path. They are **(DEFERRED)** for MVP; see
|
||||
> [`product/data-model/03-services-and-pricing.md`](../../../product/data-model/03-services-and-pricing.md).
|
||||
> Note them in your report; **do not build** them.
|
||||
|
||||
## 2. Required reading (do this first)
|
||||
|
||||
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
|
||||
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md).
|
||||
- **Product — business rules (source of truth):**
|
||||
[`product/business/03-service-catalog-and-pricing.md`](../../../product/business/03-service-catalog-and-pricing.md)
|
||||
— admin defines the catalog skeleton (categories + configurable option groups/values, addable without a
|
||||
schema change); each nurse defines their own variants (category + chosen option combination + own price +
|
||||
price unit); the **five price units** (`per_hour`/`per_session`/`per_half_day`/`per_day`/`per_24h`); the
|
||||
auto-generated-but-editable `display_name`; **deactivate, never delete**; and that catalog is snapshotted
|
||||
onto the booking. Read **(b) Iran-specific** — why `per_24h`/`per_day` are first-class and why upfront
|
||||
pricing is the differentiator.
|
||||
- **Product — data model (source of truth):**
|
||||
[`product/data-model/03-services-and-pricing.md`](../../../product/data-model/03-services-and-pricing.md)
|
||||
— the three admin layers + two nurse layers, **why the EAV configurability is load-bearing**, why the
|
||||
**variant is the bookable unit (not the nurse)**, the `UNIQUE(variant_id, option_group_id)` "one value per
|
||||
dimension" rule, the **NULL `service_category_id` = cross-category** rule, and the "consider a uniqueness
|
||||
strategy on `(nurse_id, category, option-set)`" guidance you will implement as the duplicate-listing guard.
|
||||
- **Type & money rules on the wire:**
|
||||
[`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) —
|
||||
**price is IRR Rials as an integer** (`BIGINT`), no floats, and money crosses the wire as a string of
|
||||
digits; the `price_unit` enum is a stable string code; `name_fa`/`name_en` reference data returns both.
|
||||
- **Code to mirror (existing patterns):** an existing feature folder under
|
||||
`Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/` (request `record` + `internal sealed`
|
||||
handler + `OperationResult`, validator picked up by `ValidateCommandBehavior`), an
|
||||
`IEntityTypeConfiguration<T>` under `Persistence/Configuration/<Area>Config/`, a `sealed` controller under
|
||||
`Baya.Web.Api/Controllers/V1/` (`BaseController`, inject `ISender`, `[controller]`/`[action]` snake_case
|
||||
tokens, `base.OperationResult(...)`), the b1 reference-data **seed** pattern (how seeded `name_fa`/`name_en`
|
||||
rows were inserted in the baseline migration), and how reads use `AsNoTracking()` + `.Select()` projection
|
||||
+ pagination + `ICacheService`.
|
||||
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
|
||||
(envelope, snake_case routes, status codes, mandatory list pagination, localisation of `name_fa`/`name_en`).
|
||||
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-3.md` (the
|
||||
`nurse_profiles` shape + the admin/nurse policies) and `.../after-backend-phase-1.md` (the migration
|
||||
baseline + config accessor + admin policy).
|
||||
|
||||
## 3. Scope — build this
|
||||
|
||||
A vertical slice per capability: entity + EF config + migration → command/query handler(s) → controller
|
||||
endpoint → contract. Everything async with `CancellationToken`; reads are `AsNoTracking()` + `.Select()`
|
||||
projection + pagination; writes go through `IUnitOfWork` with a single `CommitAsync`. Money is IRR `BIGINT`.
|
||||
|
||||
### 3.1 Entities, configs & migration
|
||||
|
||||
Add these five tables as **one additive EF Core migration** on top of b1's baseline. One
|
||||
`IEntityTypeConfiguration<T>` per entity in `Persistence/Configuration/CatalogConfig/`. All catalog rows
|
||||
carry a **`name_fa` (primary) + `name_en`** pair — never persist a category/group/value without the Persian
|
||||
label.
|
||||
|
||||
- **`service_categories`** — admin-managed top-level care types; the primary search dimension.
|
||||
- Columns: `id` (BIGINT PK), `name_fa` (NVARCHAR, required), `name_en` (NVARCHAR, required),
|
||||
`description_fa`/`description_en` (NVARCHAR, nullable), `icon_key` (NVARCHAR, nullable — UI glyph),
|
||||
`sort_order` (INT, for ordered display), `is_active` (BIT, default 1), plus the audit fields stamped by
|
||||
the interceptor (`CreatedAt/ModifiedAt/CreatedById/ModifiedById`) and soft-delete (`deleted_at`).
|
||||
- **Seed** the five MVP categories with both labels: **Elderly Care** (مراقبت از سالمند), **Post-Surgery
|
||||
Recovery** (مراقبت پس از جراحی), **Infant Care** (مراقبت از نوزاد), **Chronic Illness Management**
|
||||
(مدیریت بیماری مزمن), **Companionship** (همراهی / مراقبت روزمره). Seed in the migration (b1 baseline
|
||||
pattern) so a nurse can build a variant immediately.
|
||||
- Index: `(is_active, sort_order)` for the public ordered list; soft-delete query filter (`!IsDeleted`).
|
||||
- **`service_option_groups`** — admin-managed configurable **dimensions** (e.g. نوع شیفت / shift type,
|
||||
تعداد بیمار / patient count).
|
||||
- Columns: `id` (BIGINT PK), `service_category_id` (BIGINT FK → `service_categories`, **NULLABLE** —
|
||||
**NULL = cross-category**, applies everywhere), `name_fa`/`name_en` (required), `is_required` (BIT —
|
||||
whether a variant must answer this group), `sort_order` (INT), `is_active` (BIT), + audit + soft-delete.
|
||||
- Index: `(service_category_id, sort_order)`; soft-delete query filter. The nullable FK is deliberate —
|
||||
enforce nothing that breaks the cross-category (NULL) case.
|
||||
- **`service_option_values`** — the concrete choices within a group (e.g. شبانهروزی, ۲ نفر).
|
||||
- Columns: `id` (BIGINT PK), `option_group_id` (BIGINT FK → `service_option_groups`), `name_fa`/`name_en`
|
||||
(required), `sort_order` (INT), `is_active` (BIT), + audit + soft-delete.
|
||||
- Index: `(option_group_id, sort_order)`; soft-delete query filter.
|
||||
- **`nurse_service_variants`** — **the atomic bookable unit.** A nurse + category + chosen option combination
|
||||
at a price.
|
||||
- Columns: `id` (BIGINT PK), `nurse_id` (BIGINT FK → `nurse_profiles`), `service_category_id` (BIGINT FK →
|
||||
`service_categories`), `price` (**BIGINT — IRR Rials, integer, no float, ever**), `price_unit` (NVARCHAR
|
||||
stable code — closed set `per_hour` | `per_session` | `per_half_day` | `per_day` | `per_24h`),
|
||||
`session_count` (INT, nullable — number of sessions/units the engagement spans; relevant for
|
||||
`per_session` and packages), `display_name` (NVARCHAR — **auto-generated from the option labels, but
|
||||
nurse-editable**), `is_active` (BIT, default 1 — deactivation, never hard-delete), + audit + soft-delete.
|
||||
- **Indexes / constraints:** index on `(nurse_id, is_active)` for the nurse's offerings list and the b7
|
||||
index projection; index on `service_category_id`; the **duplicate-listing guard** (§3.3) on
|
||||
`(nurse_id, service_category_id, option-set)`; soft-delete query filter (`!IsDeleted`).
|
||||
- `price_unit` is the **only** value in this area allowed to be a closed code enum — categories, groups,
|
||||
and values are **data**, never code constants (EAV is load-bearing).
|
||||
- **`nurse_service_variant_options`** — the option values that define one variant's configuration.
|
||||
- Columns: `id` (BIGINT PK), `variant_id` (BIGINT FK → `nurse_service_variants`), `option_group_id` (BIGINT
|
||||
FK → `service_option_groups`), `option_value_id` (BIGINT FK → `service_option_values`), + audit.
|
||||
- **`UNIQUE(variant_id, option_group_id)`** — **one value per dimension per variant** (a variant cannot
|
||||
answer the same group twice). Index `(variant_id)` for loading a variant's full option set.
|
||||
|
||||
> **Do not add `nurse_search_index` here.** It is b7's denormalized read model. **Do not add
|
||||
> `variant_snapshot_json`** — it lives on `booking_requests` and is owned by b8. **Do not add
|
||||
> `nurse_availability_slots`/`_exceptions`** — DEFERRED.
|
||||
|
||||
### 3.2 Admin catalog — commands & queries
|
||||
|
||||
Feature folder `Baya.Application/Features/Catalog/`. **Admin-only** (narrowest admin policy from b1/b2);
|
||||
these mutate the platform skeleton.
|
||||
|
||||
- **`CreateServiceCategoryCommand`** / **`UpdateServiceCategoryCommand`** (`Commands/CreateServiceCategory/`,
|
||||
`.../UpdateServiceCategory/`) — set `name_fa`/`name_en` (both required), descriptions, `icon_key`,
|
||||
`sort_order`. FluentValidation: both labels non-empty.
|
||||
- **`SetServiceCategoryActiveCommand`** (`Commands/SetServiceCategoryActive/`) — activate/deactivate
|
||||
(`is_active`). **Soft state only — never hard-delete.** Deactivating a category hides it from public browse
|
||||
and from new variant creation; existing variants in that category are left intact (their bookings/history
|
||||
must survive — see §5).
|
||||
- **`CreateServiceOptionGroupCommand`** / **`UpdateServiceOptionGroupCommand`**
|
||||
(`Commands/CreateServiceOptionGroup/`, `.../UpdateServiceOptionGroup/`) — set `service_category_id`
|
||||
(**nullable — NULL marks the group cross-category**), `name_fa`/`name_en`, `is_required`, `sort_order`.
|
||||
- **`CreateServiceOptionValueCommand`** / **`UpdateServiceOptionValueCommand`**
|
||||
(`Commands/CreateServiceOptionValue/`, `.../UpdateServiceOptionValue/`) — set `option_group_id`,
|
||||
`name_fa`/`name_en`, `sort_order`, `is_active`.
|
||||
- **`GetCatalogCategoriesQuery`** (`Queries/GetCatalogCategories/`) — **public**, paginated, `is_active`
|
||||
categories ordered by `sort_order`, returning `name_fa`/`name_en`. `AsNoTracking()` + `.Select()`;
|
||||
**cache through `ICacheService`** with invalidation on any category mutation (read-heavy reference data).
|
||||
- **`GetCategoryOptionGroupsQuery`** (`Queries/GetCategoryOptionGroups/`) — **public**, for a category id,
|
||||
returns its **applicable** option groups (the category's own groups **plus** all cross-category
|
||||
(NULL-category) groups), each with its active option values, `is_required`, ordered by `sort_order`. This
|
||||
is the skeleton the nurse builder fills in and the customer browses. Cache + invalidate on group/value
|
||||
mutation.
|
||||
|
||||
### 3.3 Nurse variants — commands & queries
|
||||
|
||||
Feature folder `Baya.Application/Features/Variants/` (or a `Catalog/Variants/` sub-area, matching the
|
||||
surrounding convention). **Nurse-owner-only** for writes; tenancy via `ICurrentUser` → `nurse_profiles`.
|
||||
|
||||
- **`CreateVariantCommand`** (`Commands/CreateVariant/`) — the nurse picks a `service_category_id`, supplies
|
||||
the chosen `option_value_id` for each required group (and any optional groups they answer), sets `price`
|
||||
(IRR BIGINT) + `price_unit` (one of the five) + optional `session_count`, and an optional `display_name`
|
||||
override. The handler, in one transaction:
|
||||
1. **Validate the category** exists and is active.
|
||||
2. **Resolve applicable groups** (the category's groups + cross-category groups) and assert **every
|
||||
`is_required` group is answered exactly once** and **no group is answered twice** (one value per
|
||||
dimension); reject unknown groups/values, or a value that does not belong to its claimed group.
|
||||
3. **Duplicate-listing guard:** reject if this nurse already has a non-deleted variant in the same category
|
||||
with the **identical answered option-set** (same set of `(option_group_id, option_value_id)` pairs).
|
||||
Enforce with a DB backstop (see below) **and** an explicit pre-check returning a clean conflict
|
||||
`OperationResult`, never a raw DB exception.
|
||||
4. **Auto-generate `display_name`** from the chosen option labels (e.g. category + " · " + value labels)
|
||||
when the nurse did not override it.
|
||||
5. Insert the variant + its `nurse_service_variant_options` rows; `CommitAsync` once.
|
||||
- FluentValidation: `price > 0`, `price_unit` in the closed set, `session_count` null or `> 0`, at least
|
||||
the required groups present.
|
||||
- **DB backstop for the duplicate guard:** because the option-set is multi-row, a plain composite unique
|
||||
index is insufficient. Persist a deterministic **`option_set_hash`** column on `nurse_service_variants`
|
||||
(a stable hash of the sorted `(option_group_id, option_value_id)` pairs, computed in the handler) and put
|
||||
a **filtered `UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULL`** on it.
|
||||
This makes the guard race-safe; the pre-check gives the friendly message. (If b1's conventions already
|
||||
established a canonical hashing helper, reuse it.)
|
||||
- **`UpdateVariantCommand`** (`Commands/UpdateVariant/`) — owner edits `price`, `price_unit`,
|
||||
`session_count`, and `display_name`. Re-validate price/unit; if the edit would collide with another of the
|
||||
nurse's variants' option-set, reject. (Changing the **option-set** itself is treated as create-new +
|
||||
deactivate-old to keep historical meaning stable — do not silently mutate a variant's dimensions; if you
|
||||
do allow option edits, recompute `option_set_hash` and re-run the duplicate guard.)
|
||||
- **`SetVariantActiveCommand`** (`Commands/SetVariantActive/`) — owner activates/deactivates (`is_active`).
|
||||
**Deactivate, never hard-delete.** A deactivated variant cannot be booked and (via b7) must drop out of the
|
||||
search index. Editing `display_name` may piggyback as `EditVariantDisplayNameCommand` or be folded into
|
||||
`UpdateVariantCommand` — match the surrounding granularity.
|
||||
- **`ListMyVariantsQuery`** (`Queries/ListMyVariants/`) — the signed-in nurse's own offerings, **active and
|
||||
inactive**, paginated, each with category label, resolved option labels, price, unit, `session_count`,
|
||||
`display_name`, `is_active`. `AsNoTracking()` + `.Select()` projection.
|
||||
- **`GetVariantQuery`** (`Queries/GetVariant/`) — a single variant with its full resolved option-set and
|
||||
labels, for the edit screen and for b8's booking-request capture to read the canonical offering. Owner or
|
||||
admin for the full view; a public-safe projection (price/unit/labels, no internal fields) backs the
|
||||
customer-facing nurse profile.
|
||||
|
||||
### 3.4 Variant-snapshot serializer (library function for b8 — not an endpoint)
|
||||
|
||||
- **`IVariantSnapshotSerializer`** (interface in `Application/Contracts/`, implementation in Application or
|
||||
Infrastructure) — a pure function `string Serialize(variant + resolved options)` that emits the canonical
|
||||
**`variant_snapshot_json`**: category id + `name_fa`/`name_en`, each `(option_group label, option_value
|
||||
label)`, `price`, `price_unit`, `session_count`, `display_name`, and the variant id, **as they are at
|
||||
serialize time**. Booking ([backend-phase-8](backend-phase-8.md)) calls this to freeze the offering onto
|
||||
`booking_requests.variant_snapshot_json` so later variant edits/deactivation never mutate past bookings,
|
||||
disputes, or invoices. **This phase ships and unit-tests the serializer; b8 persists its output.** Do not
|
||||
add the snapshot column here.
|
||||
|
||||
### 3.5 REST endpoints
|
||||
|
||||
Controllers under `Baya.Web.Api/Controllers/V1/` (`sealed`, `BaseController`, inject `ISender`,
|
||||
`[controller]`/`[action]` snake_case tokens, `base.OperationResult(...)`, narrowest authorize policy, lists
|
||||
paginated). Routes shown logically; the snake_case transformer produces the real segments.
|
||||
|
||||
| Verb & route | Maps to | Auth |
|
||||
| --- | --- | --- |
|
||||
| `POST /v1/admin/catalog/categories` | `CreateServiceCategoryCommand` | admin |
|
||||
| `PUT /v1/admin/catalog/categories/{id}` | `UpdateServiceCategoryCommand` | admin |
|
||||
| `PATCH /v1/admin/catalog/categories/{id}/active` | `SetServiceCategoryActiveCommand` | admin |
|
||||
| `POST /v1/admin/catalog/option-groups` | `CreateServiceOptionGroupCommand` | admin |
|
||||
| `PUT /v1/admin/catalog/option-groups/{id}` | `UpdateServiceOptionGroupCommand` | admin |
|
||||
| `POST /v1/admin/catalog/option-values` | `CreateServiceOptionValueCommand` | admin |
|
||||
| `PUT /v1/admin/catalog/option-values/{id}` | `UpdateServiceOptionValueCommand` | admin |
|
||||
| `GET /v1/catalog/categories` | `GetCatalogCategoriesQuery` | public |
|
||||
| `GET /v1/catalog/categories/{id}/option-groups` | `GetCategoryOptionGroupsQuery` | public |
|
||||
| `POST /v1/nurse/variants` | `CreateVariantCommand` | nurse (owner) |
|
||||
| `PUT /v1/nurse/variants/{id}` | `UpdateVariantCommand` | nurse (owner) |
|
||||
| `PATCH /v1/nurse/variants/{id}/active` | `SetVariantActiveCommand` | nurse (owner) |
|
||||
| `GET /v1/nurse/variants` | `ListMyVariantsQuery` | nurse (owner) |
|
||||
| `GET /v1/nurse/variants/{id}` | `GetVariantQuery` | owner / admin (full) · public (safe projection) |
|
||||
|
||||
### 3.6 Out of scope (DEFERRED — build the seam/hook/serializer, not the feature)
|
||||
|
||||
- **`nurse_search_index`, the `INurseSearch` seam, the search query, and index fan-out/maintenance** —
|
||||
**(DEFERRED → [backend-phase-7](backend-phase-7.md))**. Keep the variant shape projection-friendly; b7
|
||||
reads category/price/unit/`is_active` from it.
|
||||
- **`variant_snapshot_json` persistence** — **(DEFERRED → [backend-phase-8](backend-phase-8.md))**. You ship
|
||||
the serializer (§3.4); b8 writes the column.
|
||||
- **`nurse_availability_slots` / `nurse_availability_exceptions`** — soft guidance, not money/safety path —
|
||||
**(DEFERRED)**; note them, don't build.
|
||||
- **Holiday/surge pricing rules engine; the lighter Companionship/daily-living *tier* as a pricing model;
|
||||
dynamic/tiered commission per category** — **(DEFERRED)**, see
|
||||
[`product/business/03-service-catalog-and-pricing.md`](../../../product/business/03-service-catalog-and-pricing.md)
|
||||
(c). Companionship ships only as a seeded **category** (data), not as a special pricing path.
|
||||
|
||||
## 4. Mocks & seams in this phase
|
||||
|
||||
**None.** Catalog and variant data are fully owned by Balinyaar's database — there is no third-party service
|
||||
to mock here. This phase **introduces no cross-cutting seam.**
|
||||
|
||||
- **Reuse `ICacheService`** (from [backend-phase-0](backend-phase-0.md)) for the read-heavy public catalog
|
||||
reads (`GetCatalogCategories`, `GetCategoryOptionGroups`) with invalidation on the matching admin mutation.
|
||||
Do **not** redefine it.
|
||||
- The **search-index writer** seam (`INurseSearch` / `ISearchIndexWriter`) that variant writes will fan out
|
||||
through is **introduced in [backend-phase-7](backend-phase-7.md)**, not here. Do not pre-build it; leave the
|
||||
registry row to b7. (Listed in
|
||||
[`mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md).)
|
||||
- `IVariantSnapshotSerializer` (§3.4) is an **internal application contract**, not an external-service seam —
|
||||
it does not go in the mock registry. It has a single real implementation.
|
||||
|
||||
Because this phase mocks nothing, there is no `mocks-registry.md` row to add — but you **must** still write
|
||||
the phase report (§8).
|
||||
|
||||
## 5. Critical rules you must not get wrong
|
||||
|
||||
- **The bookable unit is the variant, NOT the nurse.** Search (b7), booking (b8), and every pricing
|
||||
calculation operate on `nurse_service_variants` — a nurse who has *no* active variant is not bookable. Do
|
||||
not anywhere treat "a nurse" as the priced/bookable entity; the customer pays for a specific variant.
|
||||
- **`price` is IRR Rials as an integer (`BIGINT`) — no floats, anywhere.** Never store, compute, or serialize
|
||||
price as a float/decimal-with-fraction; there is no Toman in the DB or the contract. The engagement total
|
||||
is **`price` combined with `price_unit` and `session_count`** — **do NOT compute a total from `price`
|
||||
alone**; the unit and session count are load-bearing and a downstream consumer (booking) derives the total
|
||||
from all three. Money crosses the wire as a string of digits per
|
||||
[`money-and-types.md`](../../contracts/conventions/money-and-types.md).
|
||||
- **One value per dimension per variant.** Enforce **`UNIQUE(variant_id, option_group_id)`** on
|
||||
`nurse_service_variant_options` — a variant must never answer the same option group twice. Validate this in
|
||||
the handler *and* let the unique index be the authoritative backstop.
|
||||
- **All required option groups must be answered.** A `CreateVariant`/`UpdateVariant` that omits any
|
||||
applicable `is_required` group is a validation failure (clean `OperationResult`, not an exception). The
|
||||
applicable set = the category's own groups **plus** every cross-category (NULL `service_category_id`) group.
|
||||
- **A NULL-category option group applies cross-category.** `service_option_groups.service_category_id = NULL`
|
||||
means the dimension applies to *every* category. `GetCategoryOptionGroups`, the required-group check, and
|
||||
the duplicate guard must all include cross-category groups — silently dropping them is a defect.
|
||||
- **Catalog must be seeded before any nurse can create a variant.** The five seed categories ship in this
|
||||
phase's migration; a variant create against a missing/inactive category fails cleanly. Do not let variant
|
||||
creation succeed against a category that does not exist or is deactivated.
|
||||
- **Duplicate identical listings are rejected.** Two non-deleted variants for the same nurse, same category,
|
||||
and the **identical answered option-set** are not allowed — guarded by the filtered
|
||||
`UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULL` backstop plus a friendly
|
||||
pre-check conflict message. (A nurse may, of course, have many variants per category — just not two
|
||||
*identical* ones.)
|
||||
- **Deactivate, never hard-delete.** Variants (and categories/groups/values) soft-deactivate. A deactivated
|
||||
variant is unbookable and (via b7) drops out of search. **Past bookings, snapshots, disputes, and invoices
|
||||
must never be mutated by a later catalog edit or deactivation** — historical records survive via the
|
||||
snapshot (§3.4), which is the entire reason snapshotting exists.
|
||||
- **EAV is load-bearing — categories/groups/values are DATA, not code.** Do not hardcode categories, option
|
||||
groups, or option values as C# enums/constants. The **only** closed code enum in this area is the
|
||||
`price_unit` set (`per_hour`/`per_session`/`per_half_day`/`per_day`/`per_24h`). Admins must be able to add a
|
||||
new dimension as rows with **no migration**.
|
||||
- **Every catalog row carries `name_fa` (primary) + `name_en`.** Never persist or return a category/group/
|
||||
value without both labels; the client picks by locale (it never derives a label from a code).
|
||||
- **Tenancy & authority.** Only the **owning nurse** may create/edit/deactivate their variants (check
|
||||
`ICurrentUser` → `nurse_profiles`, not just a role); only **admins** may touch the catalog skeleton
|
||||
(categories/groups/values). A nurse editing another nurse's variant is a `403`/`NotFound`, never a success.
|
||||
|
||||
## 6. Definition of Done
|
||||
|
||||
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
|
||||
|
||||
- [ ] `service_categories`, `service_option_groups`, `service_option_values`, `nurse_service_variants`,
|
||||
`nurse_service_variant_options` exist via **one additive migration** with the §3.1 constraints:
|
||||
`UNIQUE(variant_id, option_group_id)`; the nullable `service_option_groups.service_category_id`; the
|
||||
filtered duplicate-listing `UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS
|
||||
NULL`; `price` as `BIGINT`; soft-delete query filters. The **five seed categories** (`name_fa`+`name_en`)
|
||||
are present.
|
||||
- [ ] Admin CRUD (`Create/Update/SetActive` for categories; `Create/Update` for option groups & values) and
|
||||
the public `GetCatalogCategories` / `GetCategoryOptionGroups` queries are implemented as CQRS features
|
||||
with validators and the §3.5 endpoints, returning the standard `OperationResult` envelope. The public
|
||||
reads are cached via `ICacheService` and invalidated on the matching mutation.
|
||||
- [ ] Nurse `CreateVariant` / `UpdateVariant` / `SetVariantActive` / `ListMyVariants` / `GetVariant` are
|
||||
implemented with: required-group enforcement (incl. cross-category groups), one-value-per-dimension,
|
||||
the duplicate-listing guard (pre-check + DB backstop), auto-generated-but-editable `display_name`, and
|
||||
owner-tenancy. Variants deactivate, never hard-delete.
|
||||
- [ ] `IVariantSnapshotSerializer` is implemented and **unit-tested** (its JSON contains category labels,
|
||||
each option label, price, unit, session count) — ready for b8 to persist.
|
||||
- [ ] Tests prove: admin creates category + required group + values; a nurse builds a valid variant; a
|
||||
**duplicate identical listing is rejected**; **missing a required group fails validation**; `ListMyVariants`
|
||||
returns the nurse's active + inactive variants. (See §7.)
|
||||
- [ ] `dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green including this phase's tests.
|
||||
- [ ] The contract `dev/contracts/domains/catalog.md` is written and the `swagger.json` snapshot is refreshed;
|
||||
the `server/CLAUDE.md` *Project map* notes the new `Features/Catalog` (+ variants) area and the
|
||||
`CatalogConfig` configuration folder.
|
||||
|
||||
## 7. How to test (what a human can verify after this phase)
|
||||
|
||||
Run the API (`dotnet run --project src/API/Baya.Web.Api/...`) against a reachable SQL Server; use Swagger or
|
||||
curl. The expected results below become the "what can be tested" section of your report.
|
||||
|
||||
1. **Catalog is seeded.** `GET /v1/catalog/categories` → `200`, lists the five seed categories with
|
||||
`name_fa`+`name_en`, ordered by `sort_order`, active only.
|
||||
2. **Admin builds a dimension.** As admin, `POST /v1/admin/catalog/option-groups` with
|
||||
`service_category_id` = Elderly Care, `name_fa: "نوع شیفت"`, `name_en: "Shift type"`, `is_required: true`
|
||||
→ `200`. Then `POST /v1/admin/catalog/option-values` twice (e.g. `روزانه / Daytime`, `شبانهروزی /
|
||||
Live-in`) → both `200`. `GET /v1/catalog/categories/{elderly_id}/option-groups` → returns the new required
|
||||
group **plus any cross-category groups**, each with its values.
|
||||
3. **Nurse builds a valid variant.** As a nurse, `POST /v1/nurse/variants` with the Elderly category, the
|
||||
required shift-type value = شبانهروزی, `price: "8000000"` (IRR string), `price_unit: per_24h` → `200`,
|
||||
variant created `is_active: true`, with an auto-generated `display_name` containing the category + option
|
||||
labels.
|
||||
4. **Duplicate identical listing is rejected.** Repeat the exact same `POST /v1/nurse/variants` (same
|
||||
category + same option-set) → a clean **conflict** `OperationResult` failure (not a 500, not a raw DB
|
||||
exception).
|
||||
5. **Missing a required group fails validation.** `POST /v1/nurse/variants` for Elderly **without** the
|
||||
required shift-type value → a `400`/validation `OperationResult` failure naming the missing required group.
|
||||
6. **One value per dimension.** Attempt to send two values for the same option group in one create → rejected
|
||||
(handler + the `UNIQUE(variant_id, option_group_id)` backstop).
|
||||
7. **List my variants (active + inactive).** `GET /v1/nurse/variants` → both the active variant and (after
|
||||
`PATCH /v1/nurse/variants/{id}/active` → inactive) the deactivated one appear, visually/dataly distinct;
|
||||
the deactivated one is flagged unbookable. The variant row is **never** hard-deleted.
|
||||
8. **Tenancy.** As a *different* nurse, `PUT /v1/nurse/variants/{id}` on the first nurse's variant →
|
||||
`403`/`NotFound`, never a success.
|
||||
9. **Snapshot serializer (unit test).** A unit test serializes a variant + its options and asserts the JSON
|
||||
carries the category labels, each option label, price, `price_unit`, and `session_count`.
|
||||
|
||||
## 8. Hand off & document (close the phase)
|
||||
|
||||
- **Docs to update (same change):** `server/CLAUDE.md` *Project map* — add the `Features/Catalog`
|
||||
(categories/option-groups/option-values) and the nurse `Variants` feature area, the five new tables + the
|
||||
`Persistence/Configuration/CatalogConfig/` folder, and the `IVariantSnapshotSerializer` contract (and that
|
||||
it is consumed by b8). If you established a reusable option-set hashing helper, note it in
|
||||
`server/CONVENTIONS.md`. If you discovered/decided any business rule not already in the product docs,
|
||||
reflect it in
|
||||
[`product/business/03-service-catalog-and-pricing.md`](../../../product/business/03-service-catalog-and-pricing.md)
|
||||
or [`product/data-model/03-services-and-pricing.md`](../../../product/data-model/03-services-and-pricing.md)
|
||||
(no invented rules — record decisions, and regenerate the HTML view per `product/CLAUDE.md` if you touched
|
||||
Markdown).
|
||||
- **Contract to write:** publish **`dev/contracts/domains/catalog.md`** (the §3.5 routes; request/response
|
||||
shapes for categories, option groups, option values, and variants; the `price_unit` enum; the `name_fa`/
|
||||
`name_en` pairing; the IRR-string money format; the required-group/duplicate-listing failure cases and
|
||||
status codes — `409` on a duplicate listing, `400` on a missing required group; examples) per
|
||||
[`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) and
|
||||
[`money-and-types.md`](../../contracts/conventions/money-and-types.md), starting from the
|
||||
[domain contract template](../../contracts/domains/_TEMPLATE.md). Refresh the `swagger.json` snapshot per
|
||||
[`../../contracts/openapi/README.md`](../../contracts/openapi/README.md) so
|
||||
[frontend-phase-4-b5](../frontend/frontend-phase-4-b5.md) can derive its types (it does not guess shapes).
|
||||
- **Handoff & report:** write `shared-working-context/backend/handoff/after-backend-phase-5.md` (the catalog
|
||||
is seeded and admin-CRUDable; the public catalog browse endpoints are live; nurses can build/edit/deactivate
|
||||
variants; the `IVariantSnapshotSerializer` is ready for b8; **what b7 must read** from the variant for the
|
||||
search index; **what f4-b5 can now build** — category grid + service builder; the duplicate-listing,
|
||||
required-group, and one-value-per-dimension rules the frontend must respect). Append your phase summary to
|
||||
`shared-working-context/backend/STATUS.md`, and write `reports/backend-phase-5-report.md` (what was built,
|
||||
what is now testable and exactly how — the §7 steps — that **nothing is mocked** in this phase, the contract
|
||||
produced, and follow-ups for b7 (index projection) and b8 (snapshot persistence)). This phase adds **no**
|
||||
`mocks-registry.md` row (it mocks nothing) — state that explicitly in the report so the next agent doesn't
|
||||
go looking.
|
||||
- **Memory:** save a `project`-type memory note for the non-obvious decisions here — the **variant is the
|
||||
bookable unit** principle, the **EAV / NULL-category cross-category** rule, the **`option_set_hash` +
|
||||
filtered-unique** duplicate-listing strategy, and the **price + price_unit + session_count (never price
|
||||
alone)** total rule — with a one-line `MEMORY.md` pointer.
|
||||
Reference in New Issue
Block a user