# Flow — onboarding-nurse

> Last verified: 2026-08-02 against commit `c841bde`

**Actor(s):** nurse · **Status:** partial
**Client:** partial · **Server:** real
**Business source:** [product/business/01-actors-and-onboarding.md](../../product/business/01-actors-and-onboarding.md)
**Integration:** [nurse.md](../integration/domains/nurse.md) · [profiles.md](../integration/domains/profiles.md)

## What it does

A phone-verified account becomes a listable nurse: pick the nurse role, write the profile families will
read, register the IBAN that earnings land in, and watch one checklist say what is still missing before
the listing goes live. Verification itself is a separate journey — [nurse-verification.md](nurse-verification.md);
this flow is everything around it.

## Screens

| Step | Route | Component / notes |
| --- | --- | --- |
| 0 | `/fa/select-role` | [`SelectRole`](../../client/src/components/auth/SelectRole.tsx) — only `customer`/`nurse` offered. Reached programmatically (`resolveRoleDestination` on an empty `roles`), never linked |
| 1 | `/fa/nurse` | thin RSC → `NurseDashboardScreen` → [`DashboardActivationSlot`](../../client/src/app/[locale]/(private-routes)/nurse/DashboardActivationSlot.tsx) → the shared `ActivationChecklist` |
| 2 | `/fa/nurse/profile` | client `page.tsx`; react-hook-form, three `FormSection`s (معرفی / تجربه و تحصیلات / تخصص‌ها) + avatar upload + a `beforeunload` guard on a staged-but-unsaved avatar |
| 3 | `/fa/nurse/profile/preview` | «نمایهٔ عمومی من» — the C3 dossier composed from the nurse's **own** cached queries, so it renders pre-publish with no search-index dependency |
| 4 | `/fa/nurse/bank` | an accounts *section*: submit شبا + holder, watch pending → verified/mismatch, «افزودن حساب دیگر», make-primary |
| — | `/fa/nurse/practice`, `/fa/nurse/services` | the checklist is also mounted above the offerings list (`MyServicesList.tsx:92`) next to `PublishGate` |

## API

| Call | Endpoint | Notes |
| --- | --- | --- |
| read profile | `GET /api/v1/nurse_profiles/me` | 404 → `null` (empty form) via `orNull` (`profiles/apis/clientApi.ts:19-26`) |
| save profile | `POST /api/v1/nurse_profiles/upsert` | body carries 5 fields only — see the `avatarUrl` gap |
| avatar | `POST /api/v1/nurse_profiles/avatar` | `multipart/form-data`; `clientFetch` must not set `Content-Type` |
| go live | `POST /api/v1/nurse_profiles/set_accepting_bookings` | the real switch behind `PublishGate`; reindexes in-transaction |
| bank list / add / primary | `GET nurse_bank_accounts/list`, `POST .../add`, `POST .../set_primary/{id}` | `add` is `sensitive` 20/min |
| role | `POST /api/v1/me/select_role` | `RoleNames.SelfAssignable = [customer, nurse]` (`RoleNames.cs:19`) |

Shapes live in [profiles.md](../integration/domains/profiles.md) and [nurse.md](../integration/domains/nurse.md).
`POST nurse_bank_accounts/verify_ownership/{id}` is wired server-side and implemented at
`nurse/apis/clientApi.ts:30` but **no hook or component ever calls it**.

## Rules that must hold

| Rule | Where enforced |
| --- | --- |
| **`is_verified` is never client-settable.** `UpsertNurseProfileCommand` has no such field; the handler's `Apply()` writes only bio/years/education×2/specializations. Only the verification finalize transaction flips it (INV-18) | `UpsertNurseProfileCommand.cs`, `.Handler.cs` |
| **`iban_hash` is UNIQUE platform-wide** — deterministic hash checked in the handler, `UNIQUE(iban_hash)` as the DB backstop | `AddNurseBankAccountCommand.Handler.cs:36-39` |
| **Write-then-masked IBAN.** Every read model returns `ibanMasked` (last-4); the full IBAN is never re-served | [nurse.md](../integration/domains/nurse.md) |
| **A verified *primary* IBAN with `matchedNationalId == true` is the first-payout gate** — not a search gate. No account ⇒ the nurse accrues a balance and is skipped with a recorded reason | [payouts.md](../integration/domains/payouts.md); `product/business/10` §(b) |
| **`is_searchable = is_verified AND is_accepting_bookings AND status != suspended AND variant.is_active`** (INV-17). Bio and avatar are **not** in it | `SearchIndexMaintainer.cs:248-249` |
| A profile row does **not** exist until the first `upsert`; `select_role` only grants the role | `SelectRoleCommand.Handler.cs` |
| Bank `add` requires a nurse profile to already exist ("Create your profile first.") — profile precedes bank | `AddNurseBankAccountCommand.Handler.cs:32-34` |

## How to test

Log in as **09120000001** (زهرا عزیزی, verified nurse) — see [testing-setup.md](testing-setup.md).

1. Open `/fa/nurse`. **Expect:** the «راه‌اندازی حساب» card. **Actual today:** it renders *incomplete* with
   «احراز هویت و مدارک» un-passed, because the verification row reads a client mock (see gaps). It never
   collapses to the green «فعال در جستجو» state for any account.
2. Open `/fa/nurse/profile`. **Expect:** bio «پرستار سالمند با هشت سال سابقه مراقبت در منزل.», years `8`,
   level «کارشناسی», field «پرستاری». A warning card «پروفایل شما هنوز فعال نیست» is shown — also mock-driven,
   and wrong for this account (`GET nurse_profiles/me` → `isVerified: true`).
3. Edit the bio and save. **Expect:** a success toast and the value persisted on reload. Verified live:
   `POST nurse_profiles/upsert` → `200`.
4. Same call with `"isVerified": false, "isAcceptingBookings": false` injected into the body.
   **Expect (verified live):** `200` with `isVerified: true`, `isAcceptingBookings: true` — the guard holds.
5. Open `/fa/nurse/bank`. **Expect:** one card, «تأییدشده», «بانک ملت», `••••9012`, primary.
6. Submit `IR820170000000123456789012` (nurse 1's own seeded IBAN) via «افزودن حساب دیگر».
   **Expect (verified live):** `400` `{"Iban":["This IBAN is already registered."]}` and no new row. The UI
   shows only the generic «ثبت این حساب ممکن نشد…» toast.
7. Open `/fa/nurse/profile/preview`. **Expect:** the C3 dossier rendered from own data. The avatar renders as
   a fallback icon, not the photo — see the `file://` gap.

**Seeded-world limits.** Nothing exercises the *first-run* path: all three demo nurses already have a profile,
and nurses 1–2 already have a verified primary IBAN. To see the empty profile → 404 → blank form, the
"no nurse profile yet" bank rejection, or `select-role`, you need a **new** account, which the phone-OTP
login cannot mint for an unseeded phone. `09120000003` (unverified, `in_review`) is the closest stand-in for
a mid-onboarding nurse; its token in `tokens.env` returned `401` at this stamp, so its state was **not**
re-probed live.

## Known gaps

- **The activation checklist mixes real and mocked truth.** `useActivationChecklist.ts:43-47` folds four real
  domains (`profiles`, `catalog`, `serviceAreas`, `nurse`) with one mocked one (`verification`,
  `USE_VERIFICATION_MOCK = true` at `verification/constants.ts:9`). The «احراز هویت و مدارک» row is browser
  state, not server state.
- **Every nurse looks unverified on every page load.** The verification mock's `steps` starts `[]`
  (`verification/apis/mockApi.ts:40`) and `getStatus` returns that aggregate, so `isApproved()` is `false`
  for the seeded, server-verified nurse 1 — and the module state resets on every reload/HMR. Consequences:
  the checklist never reaches «فعال در جستجو»; `PublishGate` renders the *blocked* branch and hides the real
  `set_accepting_bookings` CTA; `/fa/nurse/profile` shows a false «پروفایل شما هنوز فعال نیست» banner.
- **`isSearchVisible` is not the server's gate, despite the component saying it is.**
  `useActivationChecklist.ts:35-41` claims the four rows "are exactly the server's `is_searchable` gate". They
  are not: the `profile` row (`bio.trim() !== '' && avatarUrl != null`, `:31-33`) is client-invented and the
  server never reads it, while `is_accepting_bookings` — which *is* in the gate — is excluded from `searchRows`
  and tracked separately. A nurse can pass all four rows and still be invisible.
- **The avatar URL is unrenderable on the default seam.** `LocalDiskObjectStorage.GetUrl` returns
  `new Uri(localPath).AbsoluteUri` (`LocalDiskObjectStorage.cs:51`), so `nurse_profiles/me` serves
  `file:///C:/Users/.../avatars/nurse/1/….png` (verified live). A browser cannot load `file://` from an
  `http://` page — the `<Avatar>` on `/fa/nurse/profile` and the preview both fall back to the placeholder
  icon, while the checklist's `profile` row counts the non-null URL as passed.
- **`avatarUrl` on the upsert input is dead on the real path.** `UpsertNurseProfileInput.avatarUrl`
  (`profiles/types.ts:40`) is populated by the form (`nurse/profile/page.tsx:154`) but
  `profilesClientApi.upsertNurseProfile` (`profiles/apis/clientApi.ts:85-95`) never sends it. Harmless today
  (the multipart route already persisted it) but it is a mock-era field the real client silently drops.
- **Duplicate IBAN is `400`, not `409`.** [nurse.md](../integration/domains/nurse.md) says "the second `add`
  returns a `409`"; the handler returns `FailureResult` → `400` with a field error (verified live). The
  integration doc is wrong.
- **The duplicate-IBAN message never reaches the nurse.** `nurse/bank/page.tsx:51` toasts the generic
  «ثبت این حساب ممکن نشد. شبا را بررسی کرده و دوباره تلاش کنید.» and discards the server's
  `{"Iban":["This IBAN is already registered."]}`, so a nurse re-entering their own IBAN is told to check it.
- **The ownership inquiry ignores the national id.** `MockBankAccountOwnershipVerifier` decides from the IBAN
  alone (`:21-26`) and matches everything except `Seams:BankOwnership:MismatchIban`. A nurse who has not done
  identity KYC has `users.national_id = NULL` (`NurseIdentityContext.cs:9-10`) and still gets
  `matchedNationalId = true` — the payout gate opens on a claim nothing checked.
- **`verifyOwnership` is a dead seam op on the client.** Declared (`nurse/types.ts:41`), implemented
  (`nurse/apis/clientApi.ts:30`), server route live — but no hook exists, so a `mismatch` account has no
  re-inquiry affordance; the UI only offers "re-enter the IBAN".
- **`/fa/select-role` cannot be reached with any seeded account** — all 8 demo users already hold a role, so
  the role picker is untestable end-to-end today.
- **The bank page polls forever on a stuck inquiry.** `useNurseBankAccounts` refetches every 2 s while any
  account is `pending` (`nurse/constants.ts:14`) with no ceiling and no timeout copy.
