# Contract — Nurse verification & credentials (backend phase b6) > The trust engine: a data-driven verification pipeline (checklist of steps), the admin review queue, the > structured credential registry, the transactional `nurse_profiles.is_verified` flip, the admin-triggered > credential-expiry scan, and the public trust badge. Assumes > [`../conventions/api-conventions.md`](../conventions/api-conventions.md) + > [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema: > [`../openapi/swagger.v1.json`](../openapi/README.md) (refreshed for b6 — all 15 endpoints). **Status:** live as of backend-phase-b6 · **Frontend consumer:** frontend-phase-f5-b6 (public trust badge → f6) > **Routing note.** Routes are **action-style** (`[controller]/[action]`, snake_cased) to match the codebase > convention and the dynamic-permission key scheme — e.g. `POST api/v1/nurse_verification/submit`, > `POST api/v1/admin_verifications/steps/{stepId}/decide`. Mutations use **POST**; ids come from the > **route**, never the body. All responses use the standard `ApiResult` envelope (payload under `data`); > JSON bodies/fields are **camelCase**. ## Enums used - `verification_status` (`nurse_verifications.status` — the aggregate): `not_started` | `pending` | `in_review` | `approved` | `rejected` | `suspended`. **This is the single source of verification truth.** - `verification_step_status` (`verification_steps.status`): `not_started` | `pending` | `in_review` | `passed` | `failed` | `expired`. - `step_type_code` (the six seeded, **stable** codes): `identity_kyc` | `shahkar_match` | `moh_competency_license` | `ino_membership` | `criminal_record` | `bank_account_verification`. - `credential_type`: `moh_competency_license` | `ino_membership` | `criminal_record` — the three credential-bearing steps that record a `nurse_credentials` row on approval. - `verification_method` (how a credential was verified): `manual` | `portal` | `api`. Today every real credential resolves `manual` (admin review); `api` is reserved for a future MoH/INO portal lookup. ## Key semantics (read first) - **`nurse_verifications.status` is the SINGLE source of verification truth.** `nurse_profiles.is_verified` is the **only derived boolean** and is flipped **only inside the finalize transaction** (a re-aggregate after a step decision) or reversed inside a suspend/expiry transaction. The client never sets or infers `is_verified` — it reads `isBookable` / `isVerified` off the API. - **A nurse becomes bookable only when the aggregate reaches `approved`.** `VerificationStatusDto.isBookable` is the flag to gate the nurse UI on; `blockingSteps` names what still stands in the way. - **Steps are automated or manual.** Automated steps (`isAutomated:true` — `identity_kyc`, `shahkar_match`, `bank_account_verification`) run via a `/run` endpoint against a mocked vendor seam. Manual steps (`moh_competency_license`, `ino_membership`, `criminal_record`) take a document upload and wait for an admin decision. - **Credentials never leak their number.** `nurse_credentials.credential_number` is **encrypted at rest and NEVER serialized** on any DTO. The public **trust badge exposes credential TYPES only**, never numbers. - **Identity is cross-checked.** A credential's `holderName` is checked against the verified identity name; a mismatch → **400** and **no credential is recorded**. Bank verification enforces a **money-mule guard**: the IBAN holder's national id must equal the verified nurse national id. - **Deactivate step-types, never delete.** `DELETE` on a step-type sets `is_active=false`; a step-type `code` is **immutable once in use**; a duplicate `code` → **409**. - **Expiry is admin-triggered for now.** Time-limited steps (e.g. `criminal_record`) lapse to `expired`; `scan_expiring` is the manual entry point that reverts them and re-gates bookability. The scheduled cron is **deferred** (config key `verification_expiry_scan_cadence_hours`, int, default `24`). - **All vendor/money calls are mocked** behind DI seams (deterministic) — no real KYC, Shahkar, credential, or bank call happens. See [Mocks](#mocks). ## Nurse verification — `NurseVerificationController` (`[Authorize]`; nurse role enforced in handler; tenancy-scoped to the signed-in nurse) ### `POST api/v1/nurse_verification/submit` - **Purpose:** open (or re-open) the nurse's verification and seed the checklist. - **Body:** none. - **`data`:** `VerificationStatusDto`. - **Notes:** upserts the `nurse_verifications` header and **seeds one `verification_step` per active required step-type** (snapshotting `is_automated` at seed time). **Idempotent** — never duplicates a step; adds only newly-required ones on a re-submit. `400` if the caller has no nurse profile; `401` unauthenticated; `403` non-nurse. ### `GET api/v1/nurse_verification` - **Purpose:** the nurse's own checklist + aggregate status + blocking summary. - **`data`:** `VerificationStatusDto` — `status`, `isBookable`, `blockingSteps` (step codes still blocking), `steps[]`. Returns a **`not_started` empty checklist** if the nurse never submitted (not a 404). ### `POST api/v1/nurse_verification/steps/{stepId}/upload_url` - **Purpose:** get a signed PUT URL for a manual step's document. - **Body:** `{ contentType, fileName? }`. - **`data`:** `UploadUrlResult` (`objectStorageKey`, `uploadUrl`). **Manual (non-automated) steps only.** Echo `objectStorageKey` back on confirm. `400` on an automated step / bad content type; `404` if the step isn't the caller's. ### `POST api/v1/nurse_verification/steps/{stepId}/documents` - **Purpose:** confirm an uploaded document and move the manual step to `in_review`. - **Body:** `{ objectStorageKey, integrityHash, contentType, fileSizeBytes, originalFileName? }`. - **`data`:** `DocumentConfirmedResult` (`documentId`, `stepStatus`). - **Notes:** persists a `verification_documents` **metadata row only** (bytes never touch the DB) and moves the manual step to `in_review`. `404` if the step isn't the caller's. ### `POST api/v1/nurse_verification/steps/identity_kyc/run` - **Purpose:** run the automated national-ID + liveness check. - **Body:** `{ nationalId (10 digits), livenessPayload? }`. - **`data`:** `RunStepResult` (`stepId`, `stepStatus`, `failureReason?`). - **Side effects:** on **pass** populates `users.national_id` + `users.national_id_verified_at`. `400` on a malformed national id; a vendor fail comes back as `stepStatus:"failed"` + `failureReason` (still `200`). ### `POST api/v1/nurse_verification/steps/shahkar_match/run` - **Purpose:** run the phone↔national-id Shahkar match. - **Body:** none. - **`data`:** `RunStepResult`. - **Notes:** **requires identity KYC passed** (a verified national id must be present) → else `400`. **Shared-SIM is an explicit handled failure** — it fails the step and raises a `shared_sim` **support alert**. On **pass** sets `users.shahkar_verified_at`. ### `POST api/v1/nurse_verification/steps/bank_account_verification/run` - **Purpose:** run the استعلام شبا IBAN-owner ↔ national-id match (money-mule guard). - **Body:** none. - **`data`:** `RunStepResult`. - **Notes:** requires KYC passed **and** a **primary `nurse_bank_accounts` row** → else `400`. Reuses the b3 `IBankAccountOwnershipVerifier`; the **holder national id must equal the verified nurse national id**. On match sets the account's `matched_national_id=1` (the b13 first-payout gate). ## Admin step-type catalog — `AdminVerificationStepTypesController` (`[Authorize(DynamicPermission)]`, rate-limited `sensitive`) ### `GET api/v1/admin_verification_step_types?includeInactive={bool}` - **`data`:** `IReadOnlyList`. **Cached** (generation-token; any write invalidates). `includeInactive=false` (default) hides deactivated step-types. ### `POST api/v1/admin_verification_step_types` - **Purpose:** create or update a step-type (upsert on `id`). - **Body:** `{ id?, code, displayName, description?, isRequired, isAutomated, automationProvider?, sortOrder, isActive }`. `id` **null → create**; else update. - **`data`:** `VerificationStepTypeDto`. - **Notes:** `code` must be **snake_case** (`[a-z][a-z0-9_]*`) and is **immutable once the step-type is in use**. `400` invalid code/labels; `404` unknown `id` on update; **`409`** duplicate `code`. ### `DELETE api/v1/admin_verification_step_types/{id}` - **Purpose:** **deactivate** a step-type (`is_active=false`) — **never a hard delete**. - **`data`:** `bool` (success). `404` unknown id. ## Admin review queue — `AdminVerificationsController` (`[Authorize(DynamicPermission)]`, rate-limited `sensitive`) ### `GET api/v1/admin_verifications?status=&page=&pageSize=` - **Purpose:** the review queue — one row per step awaiting attention. - **Params:** `status` (default `in_review`) + pagination `page`/`pageSize`. - **`data`:** `PagedResult`. Documents carry **signed GET URLs**. ### `GET api/v1/admin_verifications/{nurseVerificationId}` - **Purpose:** the full case for a nurse. - **`data`:** `AdminVerificationDetailDto` — all steps + their documents (signed URLs) + credentials + the **identity name** for cross-check. `404` if the verification doesn't exist. ### `POST api/v1/admin_verifications/steps/{stepId}/decide` - **Purpose:** approve or reject a manual step (and, on a credential-bearing step, record the credential). - **Body:** `{ approve, rejectionReason?, credentialNumber?, holderName?, issuingAuthority?, issuedAt?, expiresAt?, verificationSource? }` — `rejectionReason` **required when `approve=false`**. - **`data`:** `ReviewStepResult` (`stepId`, `stepStatus`, `credentialId?`). - **Notes:** **manual steps only.** On **approving a credential-bearing step** (`moh_competency_license` / `ino_membership` / `criminal_record`) it records a `nurse_credentials` row — `credential_number` **ENCRYPTED** (never serialized); `holderName` **cross-checked** against the verified identity name (**mismatch → 400, no credential recorded**); **`criminal_record` requires `expiresAt`**. Writes an `audit_logs` decision record, then **re-aggregates** the verification (**may flip `is_verified`**). `400` missing `rejectionReason` / holder-name mismatch / missing required `expiresAt`; `404` step not found. ### `POST api/v1/admin_verifications/{nurseVerificationId}/suspend` - **Purpose:** suspend a verified nurse. - **Body:** `{ reason }`. - **`data`:** `bool`. - **Notes:** sets `status=suspended` and **reverses `is_verified=0` in the same transaction**. Writes an `audit_logs` record. `404` unknown verification. ### `POST api/v1/admin_verifications/scan_expiring` - **Purpose:** the admin-triggered credential-expiry scan (the cron entry point until the scheduler ships). - **Body:** `{ page?, pageSize? }`. - **`data`:** `ScanExpiringResult` (`scannedSteps`, `revertedNurses`). - **Notes:** reverts lapsed time-limited steps to `expired`, raises a `verification_expired` **support alert** + a `verification_expiry_prompt` **notification**, and **re-gates bookability**. The scheduled cron is **deferred** (config `verification_expiry_scan_cadence_hours`, int, default `24`). ## Public trust badge — `NursesController` (`[AllowAnonymous]`) ### `GET api/v1/nurses/{nurseId}/trust_badge` - **Purpose:** the public trust signal for a nurse. - **`data`:** `TrustBadgeDto` — `isVerified`, `approvedAt?`, and the **credential TYPES held** (never the numbers). **Cached** (short TTL; evicted on suspension / expiry / a step decision). `404` for an unknown nurse. ## Shared shapes _(records; camelCase on the wire; `?` = nullable; `credential_number` is never present)_ - `VerificationStepTypeDto`: `id` (long), `code` (string), `displayName` (string), `description` (string?), `isRequired` (bool), `isAutomated` (bool), `automationProvider` (string?), `sortOrder` (int), `isActive` (bool). - `VerificationStepDto`: `id` (long), `code` (string), `displayName` (string), `status` (enum), `isAutomated` (bool), `expiresAt` (datetime?), `failureReason` (string?). - `VerificationStatusDto`: `status` (enum), `isBookable` (bool), `blockingSteps` (string[] — step codes), `steps` (`VerificationStepDto[]`). - `VerificationDocumentDto`: `id` (long), `contentType` (string), `fileSizeBytes` (long), `originalFileName` (string?), `url` (string — a **short-lived signed** URL). - `NurseCredentialDto`: `id` (long), `credentialType` (enum), `holderNameSnapshot` (string), `issuingAuthority` (string), `issuedAt` (date?), `expiresAt` (date?), `verificationMethod` (enum). **`credential_number` is NEVER serialized.** - `TrustBadgeDto`: `nurseId` (long), `isVerified` (bool), `approvedAt` (datetime?), `credentialTypes` (string[] — credential **types** only). - `AdminPendingStepDto`: `nurseVerificationId` (long), `nurseId` (long), `nurseName` (string), `stepId` (long), `stepCode` (string), `stepDisplayName` (string), `status` (enum), `submittedAt` (datetime?), `documents` (`VerificationDocumentDto[]`). - `AdminStepDetailDto`: `stepId` (long), `code` (string), `displayName` (string), `status` (enum), `isAutomated` (bool), `expiresAt` (datetime?), `failureReason` (string?), `documents` (`VerificationDocumentDto[]`). - `AdminVerificationDetailDto`: `nurseVerificationId` (long), `nurseId` (long), `identityName` (string), `status` (enum), `steps` (`AdminStepDetailDto[]`), `credentials` (`NurseCredentialDto[]`). - `UploadUrlResult`: `objectStorageKey` (string), `uploadUrl` (string). - `DocumentConfirmedResult`: `documentId` (long), `stepStatus` (enum). - `RunStepResult`: `stepId` (long), `stepStatus` (enum), `failureReason` (string?). - `ReviewStepResult`: `stepId` (long), `stepStatus` (enum), `credentialId` (long?). - `ScanExpiringResult`: `scannedSteps` (int), `revertedNurses` (int). - `PagedResult`: `items` (`T[]`), `total` (int), `page` (int), `pageSize` (int). ## Side effects to know The finalize/reverse of `nurse_profiles.is_verified` (one transaction) · `users.national_id` population · `users.shahkar_verified_at` · `nurse_bank_accounts.matched_national_id` · `support_alerts` (`shared_sim`, `verification_expired`) · `notifications` (`verification_expiry_prompt`) · `audit_logs` decision records. ## Mocks All vendor/money calls are **mocked behind DI seams** (deterministic — use the test values below). See [`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md). - `IShahkarVerifier` → `MockShahkarVerifier`: **pass** unless the configured shared-SIM phone `09120000000` (→ shared-SIM handled failure) or the mismatch national id `1111111111`. - `IIdentityKycProvider` → `MockIdentityKycProvider`: passes any well-formed 10-digit national id **except** the configured fail id `0000000000`. - `ICredentialVerifier` → `MockCredentialVerifier`: the manual-admin default — always `RequiresManualReview` / `verification_method=manual`. - **Reused:** `IBankAccountOwnershipVerifier` (b3; mismatch IBAN `IR000000000000000000000000` → mismatch), `IObjectStorage` (b0; local-disk, signed PUT/GET URLs), `IFieldEncryptor` (b0; encrypts `credential_number`). ## Example — a nurse gets verified ``` # 1) open the checklist POST /api/v1/nurse_verification/submit -> data.steps seeded (one per active required step-type) GET /api/v1/nurse_verification -> { status: "pending", isBookable: false, blockingSteps: [...] } # 2) automated identity + shahkar POST /api/v1/nurse_verification/steps/identity_kyc/run { "nationalId": "1234567891" } -> stepStatus "passed" POST /api/v1/nurse_verification/steps/shahkar_match/run -> stepStatus "passed" # (a 09120000000 SIM would come back "failed" + raise a shared_sim support alert) # 3) manual credential (e.g. MoH license): upload then wait for admin POST /api/v1/nurse_verification/steps/{stepId}/upload_url { "contentType": "application/pdf" } -> { objectStorageKey, uploadUrl } # PUT the bytes to uploadUrl POST /api/v1/nurse_verification/steps/{stepId}/documents { objectStorageKey, integrityHash, contentType, fileSizeBytes } -> step -> in_review # 4) admin decides -> records the (encrypted) credential, re-aggregates, may flip is_verified POST /api/v1/admin_verifications/steps/{stepId}/decide { "approve": true, "credentialNumber": "…", "holderName": "", "issuingAuthority": "MoH", "issuedAt": "2026-01-01" } -> { credentialId } # holderName != verified identity -> 400 (no credential recorded) # 5) public badge (types only, never numbers) GET /api/v1/nurses/{nurseId}/trust_badge -> { isVerified: true, approvedAt, credentialTypes: ["moh_competency_license"] } ``` ## Changelog - b6 — initial contract: nurse verification checklist (submit/get/upload/confirm/run), automated identity-KYC / Shahkar / bank-ownership runs, admin step-type catalog (CRUD + deactivate), admin review queue (list/detail/decide/suspend/scan-expiring), public trust badge; `verification_status` / `verification_step_status` / `credential_type` / `verification_method` enums; transactional `is_verified` flip; encrypted-never-serialized `credential_number`; three new mocked vendor seams (`IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`). Scheduled expiry cron deferred. --- ## Refinement phase 3 additions (REQ-011) - **`VerificationStepDto`** gains `isRequired` (mirrors the step-type catalog; an optional step never blocks bookability). - **`POST api/v1/nurse_verification/credential_details`** (nurse) — captures the structured credential fields collected with the uploads: `{ inoNumber (required), specialties: string[], licenseNumber?, issuingAuthority?, holderName?, issuedAt?, expiresAt? }` → `VerificationStatusDto`. Upserts an `ino_membership` (and, if a license number is sent, `moh_competency_license`) `nurse_credentials` row (unverified — admin still decides) and persists `specialties` on the profile. The INO number is encrypted.