129 lines
11 KiB
Markdown
129 lines
11 KiB
Markdown
# 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.
|