create mvp path
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
# 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 1–5, `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.
|
||||
Reference in New Issue
Block a user