11 KiB
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 Integration: docs/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 — useMyVariants(), per-row activate/deactivate via useSetVariantActive, EmptyState when none |
| Go-live gate | same page (top card) | 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 — 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. 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
- Log in as
09120000001(nurse زهرا عزیزی, verified) — see testing-setup.md. - Open
/fa/nurse/practice. Expect: the services row shows a non-zero offerings count. - Open
/fa/nurse/services. Expect: aPublishGatecard at the top and 5 offering cards. The task brief and the seeder say 3 — that is stale: livenurse_variants/listreturnedtotal: 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. - 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.
- Pick شبانه, price
2000000Toman, unit «نیمروز», duration1, submit. Expect: an inline duplicate warning (not a toast) offering «ویرایش همان مورد» — this collides with variant8(cat 1 / Night / 20000000 IRR / per_half_day). Server returns exactly409 · "You already offer this exact configuration in this category.";VariantBuilder.tsx:258-260maps it to the inline state. - Go back, pick روزانه instead, price
300000Toman, unit «ساعتی». Expect: success; the list now shows the new card, display name auto-built as «مراقبت از سالمند · روزانه» (CreateVariantCommand.Handler.cs:131-134). - Deactivate that card. Expect: it stays in the list marked inactive, and disappears from
/fa/nurse/profile/preview(which filtersisActive,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_catalogis[Authorize(DynamicPermission)], whichsuper_admin/financefail — see testing-setup.md. - A production database has zero option groups.
CatalogSeed.csseeds categories only. The single «نوع شیفت» group exists becauseDemoWorldSeeder.EnsureShiftTypeGroupAsyncruns in Development only (DemoWorldDefinitions.cs:36-40says 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.FixPersianCharsline 90 (.Replace("", " ")), applied to every string property of every entity atApplicationDbContext.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 atNurseVariantsApiTests.cs:50-51documents the behaviour rather than fixing it. - A price edit is not shielded from an in-flight request.
UpdateVariantCommand.Handler.cs:33writes the new price unconditionally, andBookingRequestRepository.GetConversionSourceAsync:212-229readsr.Variant.Pricelive 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 abooking_requestsrow; the only field isBookings.VariantSnapshotJson(Booking.cs:41).- Category
iconKeyand both description fields arenullfor 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:11notes it "ships only as a seeded category, not a pricing path", but the builder offers it like any other. sessionCountis free-typed and unvalidated againstpriceUnit— nothing stopsper_24h+sessionCount: 5; seeded variant7isper_session+5, variant8isper_half_day+1, both legal.