create mvp path
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user