# 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.
