# 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 1–5 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.