11 KiB
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 Integration: profiles.md · auth.md · 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)).
Screens
| Step | Route | Component / notes |
|---|---|---|
| 0 — route | (none) | RoleRouter runs after verify_otp. resolveRoleDestination (routing.ts:33) sends roles == [] to /select-role, carrying ?role=nurse when that was the login intent |
| 1 — role | /fa/select-role |
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 — 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 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), 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 → MeController.cs:30 → SelectRoleCommandHandler. Live: 200 re-selecting customer; 403 for super_admin |
| token rotation | POST /api/v1/auth/refresh |
useSelectRole.ts:26-33 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 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) — 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/; 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); 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) |
| Patient ≠ customer | the self relation still creates a distinct patient row; the customer is never collapsed into the patient |
business/01 §(a) |
| Relation enum | parent | spouse | child | self (REQ-005, delivered) |
business/01 §(a) |
| 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 for boot, the
OTP and the account list.
All 8 demo accounts are already onboarded, so steps 1–5 cannot be walked with a seeded account. Two options:
A — inspect the finished state (any demo customer).
- Log in as
09120000010, land on/fa. Expect: Home renders (2 patients seeded), not a redirect to/fa/onboarding. - Open
/fa/profile. Expect: header «سارا محمدی» +0912*****10; the emergency card shows «بهرام محمدی» /09121110010. Nonudge_profilecard on Home, becausehasCustomerProfileistrue. - Type
/fa/select-roledirectly. Expect: the picker renders (it has noRoleGuard). 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).
POST /api/v1/auth/request_otpwith an unused Iranian mobile, e.g.09129990001. The handler creates an inactive shellusersrow with no role and no PII beyond the encrypted phone (RequestOtpCommand.Handler.cs:39-54) — creation happens before the SMS send, so a500from the telegram relay still leaves the account.GET /api/v1/dev/last_otp/09129990001, then log in at/fa/login. Expect:/mereturnsroles: []→RoleRouterreplaces to/fa/select-role.- Pick «خانواده» → expect a 200 from
select_role, a silent token rotation, then/fa, which immediately replaces to/fa/onboarding(zero patients). - Welcome → relation → patient. On save expect the «ذخیره شد» toast and a landing on
/fa, now with thenudge_profilecard visible becausehasCustomerProfileis stillfalse. - Tap the nudge →
/fa/profile→ «اطلاعات شخصی» and the emergency sheet. On save expectGET /meto fliphasCustomerProfiletotrueand the nudge to disappear.
This creates a permanent shell account on the shared remote demo DB. Prefer a local instance (testing-setup.md) if that matters.
curlon 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.hasCustomerProfilestaysfalseand the only path to the payer details is Home's dismissible-freenudge_profilecard (HomeScreen.tsx:155-163). Nothing blocks a customer from booking without an emergency contact. POST /api/v1/customer_profiles/avataris live in swagger and implemented (CustomerProfilesController.cs:37) but no client code calls it —useUploadAvataris imported only bynurse/profile/page.tsx:21. A customer can never set a photo;ProfileSummaryalways shows initials.- The customer's own
genderis unsettable through the app.auth/types.ts:20says "null until the profile flow (b3) sets it", butUpsertCustomerProfileCommandcarries no gender field (confirmed against the live swagger) and no client call sends one. A fresh account's/me.genderstaysnullforever. preferredLanguageis write-only. It round-trips (verified live) but nothing on either side reads it — the UI locale comes from the/fa|/enURL prefix andLocaleSwitcher. The language sheet atprofile/page.tsx:91-93therefore persists a preference the app ignores.- The upsert has no PATCH semantics (
profile/page.tsx:76-79): every sheet save rewrites the whole profile andusers.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 isnull(profile/page.tsx:85). A customer who opens the language sheet and saves silently commits'fa'they never chose. /fa/select-roleis reachable by any authenticated user at any time and has noRoleGuardby design, so an onboarded customer can permanently add themselves thenurserole 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.
useSelectRoleswallows 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.