backend phase 6: nurse verification & credentials (mocked vendors)
The trust engine. New `verif` schema (5 tables) + a data-driven verification pipeline: steps are rows (6 seeded step-types), not a code enum. - nurse_verifications.status is the single source of verification truth; nurse_profiles.is_verified is flipped ONLY inside the finalize transaction (VerificationAggregator: tracked verification + tracked profile -> one commit) and reversed on suspension/expiry — no in-between state. - is_automated snapshotted onto each step at submit; steps seeded from active required step-types; automated runs (identity-KYC, Shahkar, IBAN ownership) find their step by code. - users.national_id populated only on identity-KYC pass; Shahkar + IBAN owner compare against it (money-mule guard); shared-SIM -> shared_sim support alert. - Documents are metadata-only behind signed URLs; credential_number encrypted and never serialized; public trust badge exposes credential TYPES, not numbers; holder-name cross-checked against the verified identity before recording. - Admin-triggered credential-expiry scan reverts lapsed steps, re-gates bookability, raises a verification_expired alert + verification_expiry_prompt notification (scheduled cron deferred; config key verification_expiry_scan_cadence_hours). Three new mock vendor seams (IShahkarVerifier / IIdentityKycProvider / ICredentialVerifier) behind DI; reuses b3 IBankAccountOwnershipVerifier and b0 IObjectStorage/IFieldEncryptor. 15 endpoints across 4 controllers. Two migrations (tables + step-type seed). 154 tests pass, zero new warnings. Contract dev/contracts/domains/verification.md + swagger snapshot refreshed; handoff/report/mocks-registry updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,34 @@ One block per completed backend phase. Newest at the top. Backend lane writes he
|
||||
- **Notes for frontend:** <anything load-bearing>
|
||||
-->
|
||||
|
||||
## backend-phase-6 — Nurse verification & credentials (mocked vendors) — 2026-07-02
|
||||
- **Shipped:** the trust engine via one additive migration — new **`verif`** schema, **5 tables**:
|
||||
`NurseVerifications` (`status` = the **single source of verification truth**), `VerificationStepTypes`
|
||||
(seeded catalog — six stable codes `identity_kyc`/`shahkar_match`/`moh_competency_license`/`ino_membership`/
|
||||
`criminal_record`/`bank_account_verification`), `VerificationSteps` (one per required step-type; snapshots
|
||||
`is_automated`), `VerificationDocuments` (**metadata only** — bytes never in the DB), `NurseCredentials`
|
||||
(`credential_number` **encrypted, never serialized**). **15 endpoints across 4 controllers** —
|
||||
`nurse_verification` (submit/get/upload_url/documents + automated `identity_kyc`/`shahkar_match`/
|
||||
`bank_account_verification` `/run`), `admin_verification_step_types` (list/upsert/deactivate, dup code →
|
||||
409), `admin_verifications` (queue/detail/decide/suspend/scan_expiring), public `nurses/{id}/trust_badge`.
|
||||
`nurse_profiles.is_verified` is the **only derived boolean**, flipped **only inside the finalize
|
||||
transaction** (reversed transactionally on suspend/expiry). Three **new mock vendor seams**
|
||||
(`IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`); reuses b3
|
||||
`IBankAccountOwnershipVerifier` + b0 `IObjectStorage`/`IFieldEncryptor`. The expiry-scan logic ships as
|
||||
`ScanExpiringCredentialsCommand`; the **scheduled cron is deferred** (admin `scan_expiring` is the entry
|
||||
point; config `verification_expiry_scan_cadence_hours`, default 24).
|
||||
- **Contracts:** dev/contracts/domains/verification.md + openapi snapshot refreshed (yes — all 15 b6 paths).
|
||||
- **Mocked:** `IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier` → 🟡 (deterministic mocks;
|
||||
see reports/mocks-registry.md). All vendor/money calls are mocked.
|
||||
- **Gate:** build clean (0 new code warnings) / tests green (**153 pass**). Swagger exposes all 15 b6 paths.
|
||||
- **Handoff:** backend/handoff/after-backend-phase-6.md
|
||||
- **Notes for frontend:** **`isBookable`/`isVerified` are read-only, server-derived** — never infer
|
||||
verification client-side. **Credential numbers never cross the wire** (trust badge = types only).
|
||||
`step.isAutomated` drives the UI (`/run` button vs upload flow). Prereqs enforced with **400** (Shahkar
|
||||
needs KYC; bank needs KYC + a primary b3 account). Shared-SIM / vendor fails are **200 with
|
||||
`stepStatus:"failed"` + `failureReason`**, not HTTP errors. Documents are short-lived signed URLs. Routes
|
||||
are action-style POST; refresh after `select_role`. Public trust badge (f6) is `[AllowAnonymous]`.
|
||||
|
||||
## backend-phase-5 — Service catalog & nurse pricing variants — 2026-07-02
|
||||
- **Shipped:** five tables via one additive migration (`ServiceCatalogAndNurseVariants`) — new **`catalog`**
|
||||
schema `ServiceCategories` / `ServiceOptionGroups` (nullable `service_category_id` = cross-category) /
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# After backend-phase-6 — nurse verification & credentials are live (vendors mocked)
|
||||
|
||||
The trust engine now exists. A nurse opens a **verification checklist**, clears automated checks
|
||||
(identity-KYC, Shahkar, bank-ownership) and uploads documents for manual credential steps; an admin reviews,
|
||||
decides, and — when every required step passes — the platform flips `nurse_profiles.is_verified` **inside a
|
||||
single transaction**, making the nurse bookable. Credential **numbers are encrypted and never leave the API**;
|
||||
the public **trust badge** exposes only the credential **types** a nurse holds. Contract:
|
||||
[`dev/contracts/domains/verification.md`](../../../contracts/domains/verification.md); machine schema:
|
||||
`dev/contracts/openapi/swagger.v1.json` (refreshed for b6 — all 15 endpoints). **All vendor/money calls are
|
||||
mocked** behind deterministic DI seams.
|
||||
|
||||
## What the frontend (f5-b6) can now build
|
||||
- **The nurse verification checklist screen** — `GET api/v1/nurse_verification` returns `status`,
|
||||
`isBookable`, `blockingSteps[]`, and `steps[]` (each with `code`, `displayName`, `status`, `isAutomated`,
|
||||
`expiresAt?`, `failureReason?`). `POST api/v1/nurse_verification/submit` opens/seeds it (idempotent).
|
||||
Requires a nurse profile first (b3 `nurse_profiles/upsert`). Render the checklist off `steps`, gate the
|
||||
"you're bookable" state on `isBookable`, and surface `blockingSteps` as the to-do list.
|
||||
- **Identity submit (automated)** — `POST api/v1/nurse_verification/steps/identity_kyc/run`
|
||||
`{ nationalId, livenessPayload? }` → `RunStepResult` (`stepStatus`, `failureReason?`). A pass populates the
|
||||
nurse's national id; drive the next steps off it.
|
||||
- **Shahkar & bank runs (automated)** — `POST .../steps/shahkar_match/run` (no body; **needs KYC passed**)
|
||||
and `POST .../steps/bank_account_verification/run` (no body; **needs KYC + a primary bank account from b3**).
|
||||
Both return `RunStepResult`. Shared-SIM comes back as a clean `failed` + reason (not an error) — show it.
|
||||
- **Document upload for manual steps** — two-step: `POST .../steps/{stepId}/upload_url`
|
||||
`{ contentType, fileName? }` → `{ objectStorageKey, uploadUrl }`; PUT the file to `uploadUrl`; then
|
||||
`POST .../steps/{stepId}/documents` `{ objectStorageKey, integrityHash, contentType, fileSizeBytes, originalFileName? }`
|
||||
→ the step moves to `in_review`. Manual steps only (an automated step 400s the upload).
|
||||
- **The credentials / under-review / rejected states** — render each `step.status`
|
||||
(`not_started`/`pending`/`in_review`/`passed`/`failed`/`expired`) with the right affordance; `in_review`
|
||||
= "with our team", `failed`/`expired` = show `failureReason` + a redo path, `passed` = done.
|
||||
- **The public trust badge (f6)** — `GET api/v1/nurses/{nurseId}/trust_badge` (**anonymous**) → `isVerified`,
|
||||
`approvedAt?`, `credentialTypes[]`. Types only — never a credential number. Back the nurse-profile trust
|
||||
chip / verified marker with it.
|
||||
|
||||
## Live endpoints (all under `api/v1`, action-style, camelCase bodies)
|
||||
Nurse (`[Authorize]`, nurse-scoped): `nurse_verification/submit`, `GET nurse_verification`,
|
||||
`nurse_verification/steps/{stepId}/upload_url`, `nurse_verification/steps/{stepId}/documents`,
|
||||
`nurse_verification/steps/identity_kyc/run`, `nurse_verification/steps/shahkar_match/run`,
|
||||
`nurse_verification/steps/bank_account_verification/run`.
|
||||
Admin step-types (dynamic-permission): `GET admin_verification_step_types`, `POST admin_verification_step_types`,
|
||||
`DELETE admin_verification_step_types/{id}`.
|
||||
Admin review (dynamic-permission): `GET admin_verifications`, `GET admin_verifications/{nurseVerificationId}`,
|
||||
`admin_verifications/steps/{stepId}/decide`, `admin_verifications/{nurseVerificationId}/suspend`,
|
||||
`admin_verifications/scan_expiring`.
|
||||
Public (`[AllowAnonymous]`): `GET nurses/{nurseId}/trust_badge`.
|
||||
|
||||
## Rules baked into the API (don't fight them client-side)
|
||||
- **`isBookable` / `isVerified` are read-only, server-derived.** Never infer verification client-side —
|
||||
read `VerificationStatusDto.isBookable` (nurse view) or `TrustBadgeDto.isVerified` (public view). The
|
||||
aggregate `nurse_verifications.status` is the single source of truth; `is_verified` is flipped only inside
|
||||
the finalize transaction (and reversed on suspend/expiry).
|
||||
- **Credential numbers never cross the wire.** `NurseCredentialDto` / `TrustBadgeDto` carry **types** and
|
||||
metadata only. Don't build a UI that expects to display a number.
|
||||
- **Automated vs manual steps drive different UI.** `step.isAutomated=true` → a `/run` button; `false` → the
|
||||
upload flow (`upload_url` → PUT → `documents`). The list tells you which.
|
||||
- **Ordering / prerequisites** — Shahkar needs identity-KYC passed; bank verification needs KYC **and** a
|
||||
primary bank account (b3). Calling out of order → **400**; disable/guide accordingly.
|
||||
- **Shared-SIM and vendor fails are `200` with `stepStatus:"failed"` + `failureReason`**, not HTTP errors —
|
||||
surface the reason, don't treat as a crash. (Shared-SIM also raises an internal support alert.)
|
||||
- **Documents come back as short-lived signed URLs** (`VerificationDocumentDto.url`) — fetch/display fresh;
|
||||
don't cache the URL.
|
||||
- **Refresh after `select_role`** still applies (nurse scoping reads the role claim in the token).
|
||||
- **Routes are action-style POST** (`nurse_verification/submit`, `admin_verifications/steps/{id}/decide`, …),
|
||||
ids from the route, camelCase bodies — see the contract for the full list.
|
||||
|
||||
## Schema / migration
|
||||
New **`verif`** schema, one additive migration, **5 tables**: `nurse_verifications` (header; `status` = single
|
||||
source of truth), `verification_step_types` (seeded catalog — six stable codes), `verification_steps`
|
||||
(one per required step-type; snapshots `is_automated`), `verification_documents` (**metadata only** — bytes
|
||||
never in the DB), `nurse_credentials` (`credential_number` **encrypted**, never serialized). The only derived
|
||||
boolean is `nurse_profiles.is_verified`, flipped/reversed transactionally.
|
||||
|
||||
## What's mocked
|
||||
All vendor/money calls (deterministic seams; test values in the contract):
|
||||
- `IShahkarVerifier` → `MockShahkarVerifier` (pass unless shared-SIM `09120000000` / mismatch id `1111111111`).
|
||||
- `IIdentityKycProvider` → `MockIdentityKycProvider` (passes any well-formed 10-digit id except `0000000000`).
|
||||
- `ICredentialVerifier` → `MockCredentialVerifier` (manual-admin default; `verification_method=manual`).
|
||||
- **Reused:** `IBankAccountOwnershipVerifier` (b3; mismatch IBAN `IR000000000000000000000000`),
|
||||
`IObjectStorage` (b0; local-disk signed URLs), `IFieldEncryptor` (b0; encrypts `credential_number`).
|
||||
|
||||
See [`reports/mocks-registry.md`](../../reports/mocks-registry.md) for the make-it-real steps.
|
||||
|
||||
## Deferred to later phases (do not build against these yet)
|
||||
- **Scheduled expiry cron** (`CredentialExpiryScannerJob`) → the scan logic ships as
|
||||
`ScanExpiringCredentialsCommand`; today only the admin `scan_expiring` endpoint triggers it.
|
||||
- **Automated MoH/INO license lookup** (`ICredentialVerifier`, `verification_method=api`) → deferred.
|
||||
- **`fraud_flags` / ML fraud scoring** → deferred.
|
||||
- **Professional-liability-insurance step** → addable later as a step-type row (no schema change).
|
||||
- **b7 (search & matching)** reads `nurse_profiles.is_verified` for `nurse_search_index.is_searchable` — b6
|
||||
owns the flip, b7 owns the index (**not built here**).
|
||||
Reference in New Issue
Block a user