Files
baya-monorepo/archive/docs/flows/nurse-catalog-and-pricing.md
T
2026-08-02 20:01:31 +03:30

128 lines
11 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
# Flow — nurse catalog & pricing
> Last verified: 2026-08-02 against commit `c841bde`
**Actor(s):** nurse (browse side: customer, anonymous) · **Status:** partial
**Client:** real · **Server:** real
**Business source:** [product/business/03-service-catalog-and-pricing.md](../../product/business/03-service-catalog-and-pricing.md)
**Integration:** [docs/integration/domains/catalog.md](../integration/domains/catalog.md)
## What it does
A nurse turns the platform's service catalogue into her own price list. She picks a category, answers the
dimensions the catalogue defines for it, and puts a price on that exact configuration. The result — a
**variant** — is the atomic bookable unit: it is what a customer searches for, taps, and pays for. The
catalogue skeleton itself (categories → option groups → option values) is admin-owned reference data;
the nurse only composes on top of it.
## Screens
| Step | Route | Component / notes |
| --- | --- | --- |
| Offerings list | `/fa/nurse/services` | [`MyServicesList.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/nurse/services/MyServicesList.tsx) — `useMyVariants()`, per-row activate/deactivate via `useSetVariantActive`, `EmptyState` when none |
| Go-live gate | same page (top card) | [`PublishGate.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/nurse/services/PublishGate.tsx) — reads `useActivationChecklist`, fires the real `profiles` `set_accepting_bookings`; a listed variant is not a *visible* variant |
| Build / edit | same page, in-place | [`VariantBuilder.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/nurse/services/VariantBuilder.tsx) — 3-step stepper (category → options → price). No extra route; `page.tsx:18-28` swaps the body |
| Practice hub | `/fa/nurse/practice` | `NursePracticeScreen.tsx:25` reads `useMyVariants()` only for the "how many offerings" count |
| Public preview | `/fa/nurse/profile/preview` | `preview/page.tsx:47,68` — renders **only `isActive` variants** through `ServicePriceRow`, entirely from the nurse's own cache (never the search index) |
The builder's middle step is **derived, not fixed**: `VariantBuilder.tsx:140-144` drops the options step for a
category that provably has zero option groups, and blocks *Next* while a required group is unanswered
(`:134`), naming the missing dimension instead of erroring after the tap.
## API
| Call | Endpoint | Notes |
| --- | --- | --- |
| Categories | `GET /api/v1/catalog/categories` | anonymous; `clientApi.ts:28-35``CatalogController.Categories` |
| Option groups | `GET /api/v1/catalog/option_groups?category_id=` | anonymous; `clientApi.ts:37-40``CatalogController.OptionGroups` (`category_id` is snake_case, the only one) |
| My offerings | `GET /api/v1/nurse_variants/list` | `[Authorize]`, self-scoped; `clientApi.ts:42-49` |
| One variant | `GET /api/v1/nurse_variants/get/{id}` | **`[AllowAnonymous]`** by design (`NurseVariantsController.cs:46`) — a public profile deep-links a variant |
| Create | `POST /api/v1/nurse_variants/create` | `CreateVariantCommand.Handler.cs` |
| Edit price | `POST /api/v1/nurse_variants/update/{id}` | option-set is immutable on update (`UpdateVariantCommand.Handler.cs:33-37`) |
| Retire / restore | `POST /api/v1/nurse_variants/set_active/{id}` | soft only — there is no delete |
Shapes live in [catalog.md](../integration/domains/catalog.md). The seven `admin_catalog/*` authoring routes
exist on the server and have **no client caller at all** (`grep -rn admin_catalog client/src` → 0 hits).
## Rules that must hold
| Rule | Where it is enforced |
| --- | --- |
| **The catalogue is EAV**: categories → option groups → option values. Option groups are **not** seeded with the migration — `CatalogSeed.cs:7` says so explicitly ("those are admin-authored data per category"). A group with `serviceCategoryId = null` is **cross-category**. | `CatalogSeed.cs`, `GetApplicableGroupsAsync` |
| **Duplicate guard = `option_set_hash`.** SHA-256 over the sorted `(groupId:valueId)` pairs → one comparable column, so "same set of choices" becomes expressible as `UNIQUE(NurseId, ServiceCategoryId, OptionSetHash)` filtered on `DeletedAt IS NULL`. Handler pre-checks and returns a clean `409`; the index is the race backstop. | `OptionSetHash.cs:16-25`, `NurseServiceVariantConfig.cs:28-31`, `CreateVariantCommand.Handler.cs:65-69` |
| **Every required dimension must be answered, exactly once**, and every value must belong to an applicable group. | `CreateVariantCommand.Handler.cs:40-62` |
| **`PriceUnit` is a closed set of 5** (`per_hour` `per_session` `per_half_day` `per_day` `per_24h`) and is a **label, never a multiplier** — the client must not derive a total from it. | `Domain/Entities/Catalog/PriceUnits.cs`; §2b of the business-rule map |
| **Money is IRR integer.** The wire carries a digit string; the DB column is `bigint`. The **only** Toman↔Rial boundary is the price field: `tomanToRial` on submit, `rialToToman` to pre-fill an edit. | `utils/money.ts:38` (`tomanToRial`, `×10` via `BigInt`), used at `VariantBuilder.tsx:113,183`; `NurseServiceVariantConfig.cs:13` |
| **The variant snapshot freezes a variant onto a booking** so a later edit or deactivation never mutates a past booking, dispute or invoice. | `IVariantSnapshotSerializer`, `BookingFactory.cs:53``Bookings.VariantSnapshotJson` (`Booking.cs:41`) |
| **Search visibility is a separate gate.** Every create/update/set_active reindexes in the same unit of work; a row is searchable only when `is_verified AND is_accepting_bookings AND status != suspended AND variant.is_active` (INV-17). Deactivated rows stay with `is_searchable = 0`, never deleted. | `CreateVariantCommand.Handler.cs:99`, `UpdateVariant…:40`, `SetVariantActive…:34` |
| **Tenancy is 404, not 403** — another nurse's variant id resolves to "not found". | `UpdateVariantCommand.Handler.cs:28-31` |
## How to test
1. Log in as `09120000001` (nurse زهرا عزیزی, verified) — see [testing-setup.md](testing-setup.md).
2. Open `/fa/nurse/practice`. **Expect:** the services row shows a non-zero offerings count.
3. Open `/fa/nurse/services`. **Expect:** a `PublishGate` card at the top and **5** offering cards.
The task brief and the seeder say *3* — that is stale: live `nurse_variants/list` returned
`total: 5` (ids 1, 2, 3 seeded + 7, 8 created by earlier manual testing on the real path). Their
existence is itself proof the create path works end to end.
4. Tap *add*, choose «مراقبت از سالمند», then *Next*. **Expect:** a middle step titled with the
required dimension «نوع شیفت» offering روزانه / شبانه / شبانه روزی. It is required, so *Next* stays
disabled until one is picked.
5. Pick **شبانه**, price `2000000` Toman, unit «نیم‌روز», duration `1`, submit. **Expect:** an inline
duplicate warning (not a toast) offering «ویرایش همان مورد» — this collides with variant `8`
(`cat 1 / Night / 20000000 IRR / per_half_day`). Server returns exactly
`409 · "You already offer this exact configuration in this category."`;
`VariantBuilder.tsx:258-260` maps it to the inline state.
6. Go back, pick **روزانه** instead, price `300000` Toman, unit «ساعتی». **Expect:** success; the list
now shows the new card, display name auto-built as «مراقبت از سالمند · روزانه»
(`CreateVariantCommand.Handler.cs:131-134`).
7. Deactivate that card. **Expect:** it stays in the list marked inactive, and disappears from
`/fa/nurse/profile/preview` (which filters `isActive`, `preview/page.tsx:68`).
Live probes run for this stamp. Anonymous: `GET /catalog/categories`**5** active categories
(ids 15, `sortOrder = id`); `GET /catalog/option_groups?category_id=1`**1** group (`id 1`,
`serviceCategoryId: null`, `isRequired: true`, 3 values); `GET /nurse_variants/get/{id}``200`
without a token. Nurse-token: nurse 1 `total: 5`, nurse 2 `total: 2`.
All three write-path guards were exercised live against the running API and **CONFIRMED**:
| Probe | Result |
| --- | --- |
| `create` with nurse 1's existing `cat 1 / valueId 2` set | `409 · "You already offer this exact configuration in this category."` — and no row was added (`total` stayed 5) |
| `create` on `cat 3` with `options: []` | `400 · {"Options":["Required dimension(s) not answered: نوع شیفت."]}` — the cross-category group is enforced on a category that never declared it |
| `create` with a **customer** token (09120000010) | `403 · "Only a nurse can create a variant."` — the role check is in the handler, not just the attribute |
The 409 fires from the handler pre-check (`:68-69`), ahead of the index; the filtered UNIQUE index is
the race backstop, not the message source.
## Known gaps
- **No catalogue-authoring UI exists.** All seven `admin_catalog/*` routes (create/update category,
option group, option value) have zero client callers. An admin cannot add a dimension without SQL.
- **Even with a UI, no seeded admin could use it.** `admin_catalog` is `[Authorize(DynamicPermission)]`,
which `super_admin`/`finance` fail — see [testing-setup.md](testing-setup.md#-the-seeded-admins-cannot-reach-any-admin-endpoint).
- **A production database has zero option groups.** `CatalogSeed.cs` seeds categories only. The single
«نوع شیفت» group exists because `DemoWorldSeeder.EnsureShiftTypeGroupAsync` runs **in Development only**
(`DemoWorldDefinitions.cs:36-40` says so). Deployed, every builder collapses to two steps and every
variant in a category is a duplicate of every other — one price per nurse per category, forever.
- **ZWNJ is stripped from every stored Persian string.** `StringExtensions.FixPersianChars` line 90
(`.Replace("", " ")`), applied to every string property of every entity at
`ApplicationDbContext.cs:79`. Live proof: the seeder writes «شبانه‌روزی» and the API returns
«شبانه روزی» (`ش…ه ر…` — a literal U+0020). This contradicts the repo naming rule
("with a ZWNJ, always") and silently mangles nurse-typed display names. The API test at
`NurseVariantsApiTests.cs:50-51` documents the behaviour rather than fixing it.
- **A price edit is not shielded from an in-flight request.** `UpdateVariantCommand.Handler.cs:33` writes
the new price unconditionally, and `BookingRequestRepository.GetConversionSourceAsync:212-229` reads
`r.Variant.Price` **live** at conversion. The snapshot freezes at booking creation (post-payment), not
at request creation — so a nurse editing price inside the 30-min payment window changes what the
customer pays. Code-traced, not exercised live.
- **`IVariantSnapshotSerializer`'s doc-comment names the wrong table** — it says the JSON is frozen onto
a `booking_requests` row; the only field is `Bookings.VariantSnapshotJson` (`Booking.cs:41`).
- **Category `iconKey` and both description fields are `null` for all 5 categories** (live probe), so the
Home A5 grid and the builder's category tiles fall back to a generic icon and show no explainer copy.
- **The «همراهی و مراقبت روزمره» (Companionship) category is data-only** — `CatalogSeed.cs:11` notes it
"ships only as a seeded category, not a pricing path", but the builder offers it like any other.
- **`sessionCount` is free-typed and unvalidated against `priceUnit`** — nothing stops
`per_24h` + `sessionCount: 5`; seeded variant `7` is `per_session` + `5`, variant `8` is
`per_half_day` + `1`, both legal.