Files
baya-monorepo/archive/docs/flows/onboarding-customer.md
T
2026-08-02 20:01:31 +03:30

129 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
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 — onboarding-customer
> Last verified: 2026-08-02 against commit `c841bde`
**Actor(s):** customer (a family member / payer) · **Status:** partial
**Client:** real · **Server:** real
**Business source:** [product/business/01-actors-and-onboarding.md](../../product/business/01-actors-and-onboarding.md)
**Integration:** [profiles.md](../integration/domains/profiles.md) · [auth.md](../integration/domains/auth.md) · [patients.md](../integration/domains/patients.md)
## What it does
A phone that has just verified an OTP holds a session with **no public role**. This flow is everything
between that moment and a usable family app: pick "I need care" vs "I am a nurse", register the first
person you arrange care for, and — later, prompted by a nudge on Home — fill in your own payer details
and the emergency contact a booking falls back on.
The customer's own identity KYC is deliberately **not** part of it: a customer registers and browses on a
verified phone alone ([business/01 §(a)](../../product/business/01-actors-and-onboarding.md)).
## Screens
| Step | Route | Component / notes |
| --- | --- | --- |
| 0 — route | *(none)* | [`RoleRouter`](../../client/src/components/auth/RoleRouter.tsx) runs after `verify_otp`. `resolveRoleDestination` ([`routing.ts:33`](../../client/src/services/auth/routing.ts)) sends `roles == []` to `/select-role`, carrying `?role=nurse` when that was the login intent |
| 1 — role | `/fa/select-role` | [`SelectRole.tsx`](../../client/src/components/auth/SelectRole.tsx). Two radio cards, «خانواده» / «پرستار»; admin is never offered. `FocusedLayout`, **no `RoleGuard`** — resolving the role is the page's job |
| 2 — gate | `/fa` | [`HomeScreen.tsx:90-94`](../../client/src/app/[locale]/(private-routes)/(customer)/HomeScreen.tsx) — `total === 0``router.replace('/onboarding')`. Waits for a *settled* list so a post-create refetch can't bounce back |
| 3 — welcome | `/fa/onboarding` | [`OnboardingScreen.tsx`](../../client/src/app/[locale]/(private-routes)/(customer-focused)/onboarding/OnboardingScreen.tsx) phase `welcome` — brand moment, one CTA. Not a stepper step |
| 4 — relation | `/fa/onboarding` | phase `relation` — «مراقبت برای چه کسی است؟» `RelationSelect` over `parent`/`spouse`/`child`/`self`, one glyph each |
| 5 — first patient | `/fa/onboarding` | phase `patient``PatientForm` with `relation` pre-set and hidden. On success → `router.replace('/')` |
| 6 — payer details | `/fa/profile` | Customer **account hub**. Reached from Home's `nudge_profile` card ([`HomeScreen.tsx:155-163`](../../client/src/app/[locale]/(private-routes)/(customer)/HomeScreen.tsx)), shown while `me.hasCustomerProfile === false`. Three `FormDialogShell` sheets over **one** `react-hook-form` — personal (نام/نام خانوادگی), language, emergency contact |
`(customer-focused)` is a chrome-free route group: same URL space, `FocusedLayout` strips the bottom nav so
the user cannot tab away mid-setup. It still carries `RoleGuard expected=customer`; `/select-role` does not.
## API
| Call | Endpoint | Notes |
| --- | --- | --- |
| commit role | `POST /api/v1/me/select_role` | [`clientApi.ts:56`](../../client/src/services/auth/apis/clientApi.ts) → [`MeController.cs:30`](../../server/src/API/Baya.Web.Api/Controllers/V1/MeController.cs) → `SelectRoleCommandHandler`. **Live: 200** re-selecting `customer`; **403** for `super_admin` |
| token rotation | `POST /api/v1/auth/refresh` | [`useSelectRole.ts:26-33`](../../client/src/services/auth/hooks/useSelectRole.ts) rotates immediately after — role claims live **inside** the JWE, so without it the next gated call carries the stale claim. A failed rotation is swallowed by design |
| identity | `GET /api/v1/me` | the only identity source. Live for `…010`: `roles:["customer"]`, `hasCustomerProfile:true`, masked `0912*****10` |
| first patient | `POST /api/v1/patients/create` | [`useCreatePatient.ts`](../../client/src/services/patients/hooks/useCreatePatient.ts) splices the row into every cached list before invalidating, so there is no transient "0 patients" window |
| read profile | `GET /api/v1/customer_profiles/me` | **404 ⇒ `null`, not an error** ([`clientApi.ts:19-26`](../../client/src/services/profiles/apis/clientApi.ts)) — a first-run customer sees an empty form. Live: 404 for a nurse token, 200 for `…010` |
| write profile | `POST /api/v1/customer_profiles/upsert` | **403 for a non-customer** (verified live). Creates on first call, updates after |
Shapes belong to [docs/integration/domains/](../integration/domains/profiles.md); do not restate them here.
`USE_AUTH_MOCK`, `USE_PROFILES_MOCK` and `USE_PATIENTS_MOCK` are all `false` — every call above is real HTTP.
## Rules that must hold
| Rule | Value | Source |
| --- | --- | --- |
| Self-selectable roles | `customer`, `nurse` **only**; any admin sub-role ⇒ **403** | [business/01 §(a)](../../product/business/01-actors-and-onboarding.md); `RoleNames.SelfAssignable`, `SelectRoleCommand.Handler.cs:23-24` |
| A user may hold both public roles | grants are audited via `granted_by`/`granted_at`; a revoked grant is **re-activated**, never duplicated | `SelectRoleCommand.Handler.cs:44-50` |
| Customer KYC is deferred | a customer registers and browses on a verified phone alone; `national_id` is nurse-only | [business/01 §(a)/(c)](../../product/business/01-actors-and-onboarding.md) |
| Patient ≠ customer | the `self` relation still creates a **distinct** patient row; the customer is never collapsed into the patient | [business/01 §(a)](../../product/business/01-actors-and-onboarding.md) |
| Relation enum | `parent` \| `spouse` \| `child` \| `self` (REQ-005, delivered) | [business/01 §(a)](../../product/business/01-actors-and-onboarding.md) |
| PII at rest | phone, name and the emergency contact are encrypted; `/me` masks the phone to `0912*****10` | INV-21; `IFieldEncryptor`, `IdentityDefaults.MaskPhone` |
| Tenancy | the profile is resolved from `ICurrentUser`, never the body | `UpsertCustomerProfileCommand.Handler.cs:17-21` |
No money, no config rate, no deadline is involved in this flow.
## How to test
Log in as `09120000010` (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md) for boot, the
OTP and the account list.
**All 8 demo accounts are already onboarded**, so steps 15 cannot be walked with a seeded account. Two
options:
**A — inspect the finished state (any demo customer).**
1. Log in as `09120000010`, land on `/fa`.
**Expect:** Home renders (2 patients seeded), **not** a redirect to `/fa/onboarding`.
2. Open `/fa/profile`.
**Expect:** header «سارا محمدی» + `0912*****10`; the emergency card shows «بهرام محمدی» / `09121110010`.
No `nudge_profile` card on Home, because `hasCustomerProfile` is `true`.
3. Type `/fa/select-role` directly.
**Expect:** the picker renders (it has no `RoleGuard`). Choosing «پرستار» **adds** the nurse role to
this account — a real, permanent write to the shared demo DB. Do not do it casually.
**B — see the genuine first-run state (a fresh phone).**
1. `POST /api/v1/auth/request_otp` with an unused Iranian mobile, e.g. `09129990001`. The handler creates
an **inactive shell `users` row** with no role and no PII beyond the encrypted phone
([`RequestOtpCommand.Handler.cs:39-54`](../../server/src/Core/Baya.Application/Features/Identity/Commands/RequestOtp/RequestOtpCommand.Handler.cs)) —
creation happens **before** the SMS send, so a `500` from the telegram relay still leaves the account.
2. `GET /api/v1/dev/last_otp/09129990001`, then log in at `/fa/login`.
**Expect:** `/me` returns `roles: []``RoleRouter` replaces to `/fa/select-role`.
3. Pick «خانواده» → **expect** a 200 from `select_role`, a silent token rotation, then `/fa`, which
immediately replaces to `/fa/onboarding` (zero patients).
4. Welcome → relation → patient. On save **expect** the «ذخیره شد» toast and a landing on `/fa`, now with
the `nudge_profile` card visible because `hasCustomerProfile` is still `false`.
5. Tap the nudge → `/fa/profile` → «اطلاعات شخصی» and the emergency sheet. On save **expect**
`GET /me` to flip `hasCustomerProfile` to `true` and the nudge to disappear.
> This creates a permanent shell account on the **shared** remote demo DB. Prefer a local instance
> ([testing-setup.md](testing-setup.md#the-local-alternative--unverified)) if that matters.
>
> **`curl` on Git Bash mangles Persian in `-d`.** A round-trip upsert sent inline wrote `?????` into the
> DB during this verification (restored). Put the JSON in a UTF-8 file and use `--data-binary @file`.
## Known gaps
- Onboarding never creates a `customer_profile` — it creates a patient and stops. `hasCustomerProfile`
stays `false` and the **only** path to the payer details is Home's dismissible-free `nudge_profile` card
(`HomeScreen.tsx:155-163`). Nothing blocks a customer from booking without an emergency contact.
- `POST /api/v1/customer_profiles/avatar` is live in swagger and implemented
(`CustomerProfilesController.cs:37`) but **no client code calls it**`useUploadAvatar` is imported only
by `nurse/profile/page.tsx:21`. A customer can never set a photo; `ProfileSummary` always shows initials.
- The customer's own `gender` is **unsettable through the app**. `auth/types.ts:20` says "null until the
profile flow (b3) sets it", but `UpsertCustomerProfileCommand` carries no gender field (confirmed against
the live swagger) and no client call sends one. A fresh account's `/me.gender` stays `null` forever.
- `preferredLanguage` is **write-only**. It round-trips (verified live) but nothing on either side reads it
— the UI locale comes from the `/fa|/en` URL prefix and `LocaleSwitcher`. The language sheet at
`profile/page.tsx:91-93` therefore persists a preference the app ignores.
- The upsert has **no PATCH semantics** (`profile/page.tsx:76-79`): every sheet save rewrites the whole
profile *and* `users.Name`/`FamilyName`. Saving only the emergency contact re-sends the name; a stale form
can silently overwrite a name changed on another device.
- Saving any sheet writes `preferredLanguage: values.language`, whose default is `'fa'` when the served
value is `null` (`profile/page.tsx:85`). A customer who opens the language sheet and saves silently
commits `'fa'` they never chose.
- `/fa/select-role` is reachable by any authenticated user at any time and has no `RoleGuard` by design, so
an onboarded customer can permanently add themselves the `nurse` role by typing the URL. Intentional
(`role_add_later_note`), but there is no confirmation step for an irreversible grant.
- The seeded demo world contains **no un-onboarded account**, so this flow's first-run half is untestable
without minting a shell user against the shared DB. See [testing-setup.md](testing-setup.md).
- `useSelectRole` swallows a failed post-select token rotation
(`useSelectRole.ts:30-32`). The role is persisted server-side, but the client keeps a JWE without the new
claim until the fetch layer's silent refresh happens to fire — a role-gated call in between 403s.