Files
baya-monorepo/archive/build-chain/contracts/domains/verification.md
T
2026-08-02 18:48:32 +03:30

270 lines
18 KiB
Markdown

# 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<T>` 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<VerificationStepTypeDto>`. **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<AdminPendingStepDto>`. 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<T>`: `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": "<verified identity name>", "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.