# Flow — nurse-verification

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

**Actor(s):** nurse · admin · (public, read-only trust badge) · **Status:** mocked
**Client:** mock · **Server:** partial (nurse half real and live; admin half real code, unreachable — 403)
**Business source:** [product/business/02-nurse-verification.md](../../product/business/02-nurse-verification.md)
**Integration:** [docs/integration/domains/verification.md](../integration/domains/verification.md)

## What it does

A nurse proves who she is and what she is licensed to do — identity, phone binding, professional
credentials, bank ownership — and an admin reviews the manual parts. Until the pipeline says `approved`
the nurse is not bookable and does not appear in search. This is the flow the whole marketplace's trust
claim rests on.

**This is the atlas's clearest "real UI on a mocked service in front of a live server" trap.** Every nurse
screen renders in-browser fixtures from
[`verification/apis/mockApi.ts`](../../client/src/services/verification/apis/mockApi.ts) because
`USE_VERIFICATION_MOCK = true` ([`constants.ts:9`](../../client/src/services/verification/constants.ts)) —
while the server's nurse half answers correctly, right now, to a `curl`. Nothing in the browser ever calls
it. The one flag also holds the working nurse half hostage to the broken admin half.

## Screens

| Step | Route | Component / notes |
| --- | --- | --- |
| B3 status hub | `/fa/nurse/verification` | [`page.tsx`](../../client/src/app/[locale]/(private-routes)/nurse/verification/page.tsx) — the canonical view of the **one cached status query** (`useVerificationStatus`). `not_started` CTA → grouped checklist → terminal approved panel. Renders mock-only approve/reject buttons (`:84-95`) |
| checklist | (same) | `VerificationChecklist.tsx` + [`verificationSteps.ts`](../../client/src/app/[locale]/(private-routes)/nurse/verification/verificationSteps.ts) — data-driven; groups server steps into هویت / مدارک حرفه‌ای / بانک. Prepends a **synthetic `mobile_verified` step** (`MOBILE_STEP`, id `0`, always `passed`) that is not a server step — it is satisfied at phone-OTP login |
| B4 identity | `/fa/nurse/verification/identity` | national id (10-digit + checksum) + ID-card image + liveness selfie → `identity_kyc` run, chained `shahkar_match`. The two images are **local captures**, never uploaded |
| B5 credentials | `/fa/nurse/verification/credentials` | one `DocumentUpload` per manual step **in the status** (`MANUAL_CREDENTIAL_CODES`, `page.tsx:32`) + INO number, specialties, credential dates |
| B6 under review | `/fa/nurse/verification/review` | a second focused view of the **same cached query** — never a second fetch |
| admin queue | `/fa/admin/verification` | `useVerificationQueue`; per-step wire rows folded to one row per nurse |
| admin case | `/fa/admin/verification/[nurseId]` | per-step pass/reject + `DocumentViewer`; prev/next off the queue's cached page order |
| admin group root | `/fa/admin/trust` | nav only, no data |
| public | `/fa/search/nurse/:nurseId` | `TrustBadge` + `VerificationPanel` read `getTrustBadge` — see [search-and-discovery.md](search-and-discovery.md) |

`DocumentUpload` resolves its resting state as `state === 'success' || (state === 'idle' && existingDoc != null)`
([`DocumentUpload.tsx:132`](../../client/src/components/DocumentUpload/DocumentUpload.tsx)) — a just-finished
upload wins over stale server metadata, so a re-upload never snaps back to the old file name.

## API

Shapes, enums and the two vocabularies live in
[docs/integration/domains/verification.md](../integration/domains/verification.md). All 20 paths below were
confirmed present in the live OpenAPI doc (178 paths).

| Call | Endpoint | Notes |
| --- | --- | --- |
| status (B3/B5/B6) | `GET /api/v1/nurse_verification` | **live, probed 200.** `clientApi.ts:157` → `NurseVerificationController.cs:32` |
| start | `POST …/submit` | wired |
| presign / confirm | `POST …/steps/{stepId}/upload_url` · `POST …/steps/{stepId}/documents` | wired; XHR PUT direct to storage + SHA-256 integrity hash (`clientApi.ts:180-208`) |
| automated runs | `POST …/steps/{identity_kyc\|shahkar_match\|bank_account_verification}/run` | wired; each is a separately switchable seam |
| credential details | `POST …/credential_details` | **endpoint EXISTS** (`NurseVerificationController.cs:49-52`) but `clientApi.ts:212` is an empty no-op — see gaps |
| public badge | `GET /api/v1/nurses/{nurseId}/trust_badge` | **live, anonymous, probed 200** |
| admin queue | `GET /api/v1/admin_verifications` | **403 for every seeded admin** |
| admin case | `GET /api/v1/admin_verifications/{nurseVerificationId}` | 403 |
| admin decide | `POST /api/v1/admin_verifications/steps/{stepId}/decide` | 403. **Per-step only** — approval emerges when the last required step passes |
| suspend · scan_expiring | `POST …/{id}/suspend` · `POST …/scan_expiring` | exist server-side, **no client caller** |
| step-type catalog | `GET/POST /api/v1/admin_verification_step_types`, `DELETE …/{id}` | exist, **no client caller**; probed **403** |
| approve / reject / document URL | `POST …/{id}/approve`, `…/reject`, `GET …/documents/{id}/url` | **proposed, do not exist** (REQ-034) — the admin case page wires CTAs to all three |

## Rules that must hold

| Rule | Source |
| --- | --- |
| `status` is the source of truth; `nurse_profiles.is_verified` is **derived** and flips only inside the transaction that confirms every required step passed (INV-18) | [business/02](../../product/business/02-nurse-verification.md); `AdminReviewStepCommand.Handler.cs:74-78` |
| The step catalog is **data, not code** — `verification_step_types` rows. 6 seeded, all `is_required`. Adding a regulatory step is one INSERT | [business/02](../../product/business/02-nurse-verification.md) §(a); `VerificationStepTypeSeed.cs` |
| The server computes `isBookable` and names `blockingSteps`; the client must **never** derive bookability from the step array | [integration/verification.md](../integration/domains/verification.md) |
| Search visibility is one gate: `is_verified AND is_accepting_bookings AND status != suspended AND variant.is_active` (INV-17) | `SearchIndexMaintainer.cs:177,248` |
| `expired` is a normal step state, not an error — the credential-expiry scan runs every **24 h** (`verification_expiry_scan_cadence_hours`) and re-gates bookability | [business/02](../../product/business/02-nurse-verification.md) |
| The trust badge exposes credential **types** only; `credential_number` is encrypted and never serialized | [business/02](../../product/business/02-nurse-verification.md) §(c-bis) |
| INO membership locks once submitted (it feeds the public badge) | [integration/verification.md](../integration/domains/verification.md) |

## How to test

1. Log in as **09120000003** (مریم احمدی — the deliberately unverified nurse) — see
   [testing-setup.md](testing-setup.md). Boot the API with `Seams__Sms__Provider=mock` or `request_otp` 500s.
2. Open `/fa/nurse/verification`. **Expect:** the B3 grouped checklist renders and a «شبیه‌سازی بررسی ادمین»
   block with approve/reject buttons appears at the bottom. That block is the mock tell — it only renders
   when `USE_VERIFICATION_MOCK` is true.
3. Walk B4 → B5, then press the mock «تأیید» button. **Expect:** the hub flips to the green approved panel
   with a «انتشار خدمات» CTA. **This changed nothing on the server** — reload the page and the mock store
   resets to its seed.
4. Now read the server truth for the same nurse:
   `curl --noproxy '*' http://localhost:5002/api/v1/nurse_verification -H "Authorization: Bearer $T_09120000003"`.
   **Expect (probed 2026-08-02, HTTP 200):** `status: "in_review"`, `isBookable: false`,
   `blockingSteps: ["moh_competency_license","criminal_record"]`, and 4 steps — `identity_kyc` passed,
   `shahkar_match` passed, `moh_competency_license` in_review, `criminal_record` pending. Compare it with
   what step 3 showed you; they are unrelated.
5. Public badge, no auth: `curl --noproxy '*' http://localhost:5002/api/v1/nurses/1/trust_badge`.
   **Expect:** `{"nurseId":1,"isVerified":true,"approvedAt":"2026-07-26T10:52:18…","credentialTypes":["criminal_record","moh_competency_license"]}`.
   Nurse 3 returns `isVerified:false`, `credentialTypes:[]`.
6. Admin half: open `/fa/admin/verification` as **09120000020**. **Expect:** a populated queue — all of it
   in-browser fixtures. The live endpoint is **403**:
   `curl --noproxy '*' "http://localhost:5002/api/v1/admin_verifications?page=1&page_size=5" -H "Authorization: Bearer $T_09120000020"`
   → `403` (probed). Same for `/api/v1/admin_verification_step_types`.

**Seeded-world limits.** No admin can approve anything on the real path (the RBAC gap). Nurse 1 is already
`approved` but returns `steps: []`, so there is no approved-with-checklist case to look at. Nurse 3 carries
only 4 of the 6 seeded step types — `ino_membership` and `bank_account_verification` have no step rows at
all, so the bank branch of the journey cannot be walked against real data. Workaround for the whole flow:
none today; the honest demo is the mock, and the honest server check is `curl`.

## Known gaps

- `USE_VERIFICATION_MOCK = true` ([`verification/constants.ts:9`](../../client/src/services/verification/constants.ts)) suppresses a working real nurse half — 7 nurse-side ops map live, probed-200 routes. One flag covers nurse + public badge + 6 admin ops, so the nurse half cannot be flipped independently.
- `submitCredentialDetails` is an empty no-op (`verification/apis/clientApi.ts:212`) even though `POST /api/v1/nurse_verification/credential_details` exists and `SubmitCredentialDetailsCommand` matches `CredentialDetailsInput` field-for-field. **Flipping the flag today would silently drop every nurse's INO number and specialties.** The client comment, `useSubmitCredentials.ts` and [integration/verification.md](../integration/domains/verification.md) all still say "no nurse-facing endpoint" — stale.
- Every `admin_verifications` and `admin_verification_step_types` route returns **403** for `super_admin`/`finance` (`DynamicPermissionService.CanAccess`). The admin review half of this flow is untestable end to end; the mock hides it completely.
- [`nurse/verification/page.tsx:12`](../../client/src/app/[locale]/(private-routes)/nurse/verification/page.tsx) imports `__mockApproveAll`/`__mockRejectStep` from `apis/mockApi` **unconditionally**. The render is gated but the module edge is not — the mock is bundled into the nurse verification route in every build. This is the one production seam breach in the client.
- With the flag flipped there is **no** way for a nurse to observe the approved flip in a demo: the mock admin controls disappear and the real admin queue 403s.
- `foldQueueRows` (`clientApi.ts:74-96`) folds a per-step wire page to per-nurse items and leaves `stepsPassed: 0`, `stepsTotal: 0`, `hasExpiringCredential: false`, `counts` undefined; `total` stays the per-step count, so the queue's pager is nominal. A nurse's steps can straddle a page boundary (REQ-034, REQ-062).
- `approveVerification`, `rejectVerification` and `getDocumentSignedUrl` target routes that do not exist (REQ-034). The admin case page wires visible approve/reject CTAs to two of them — they would 404 on the real path.
- `AdminVerificationsController` `suspend` and `scan_expiring`, and all three `admin_verification_step_types` routes, have **no client caller** — no suspension UI and no step-catalog editor, so the "data-driven catalog" rule has no admin surface.
- The wire serves `isRequired` per step (confirmed live) but `VerificationStep` (`verification/types.ts:62-71`) omits it, and `progressCounts` assumes every step is required. Adding one optional step type would make the "X از Y" meter wrong.
- No `submittedAt` on `VerificationStatusDto` (REQ-055) → B6 omits the submitted-at line. `TrustBadgeDto` carries no per-step detail (REQ-043) → the public `VerificationPanel` is a summary only. Both confirmed against the live responses.
- The admin case route folder is `[nurseId]` but the value it passes is a **`nurseVerificationId`** (`[nurseId]/page.tsx:74`). Typing `/fa/admin/verification/3` opens verification-case 3, not nurse 3.
- Seeded data: nurse 1 (`approved`) returns `steps: []`, and nurse 3 has no `ino_membership` or `bank_account_verification` step rows — two of the six catalog steps are unexercisable in the demo world.
