270 lines
18 KiB
Markdown
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.
|