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

11 KiB
Raw Blame History

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.tsxuseMyVariants(), 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-35CatalogController.Categories
Option groups GET /api/v1/catalog/option_groups?category_id= anonymous; clientApi.ts:37-40CatalogController.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:53Bookings.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.
  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/categories5 active categories (ids 15, sortOrder = id); GET /catalog/option_groups?category_id=11 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.
  • 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-onlyCatalogSeed.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.