18 KiB
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_verifiedflip, the admin-triggered credential-expiry scan, and the public trust badge. Assumes../conventions/api-conventions.md+../conventions/money-and-types.md. Machine schema:../openapi/swagger.v1.json(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 standardApiResult<T>envelope (payload underdata); 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 anurse_credentialsrow on approval.verification_method(how a credential was verified):manual|portal|api. Today every real credential resolvesmanual(admin review);apiis reserved for a future MoH/INO portal lookup.
Key semantics (read first)
nurse_verifications.statusis the SINGLE source of verification truth.nurse_profiles.is_verifiedis 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 infersis_verified— it readsisBookable/isVerifiedoff the API.- A nurse becomes bookable only when the aggregate reaches
approved.VerificationStatusDto.isBookableis the flag to gate the nurse UI on;blockingStepsnames 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/runendpoint 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_numberis 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
holderNameis 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.
DELETEon a step-type setsis_active=false; a step-typecodeis immutable once in use; a duplicatecode→ 409. - Expiry is admin-triggered for now. Time-limited steps (e.g.
criminal_record) lapse toexpired;scan_expiringis the manual entry point that reverts them and re-gates bookability. The scheduled cron is deferred (config keyverification_expiry_scan_cadence_hours, int, default24). - All vendor/money calls are mocked behind DI seams (deterministic) — no real KYC, Shahkar, credential, or bank call happens. See 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_verificationsheader and seeds oneverification_stepper active required step-type (snapshottingis_automatedat seed time). Idempotent — never duplicates a step; adds only newly-required ones on a re-submit.400if the caller has no nurse profile;401unauthenticated;403non-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 anot_startedempty 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. EchoobjectStorageKeyback on confirm.400on an automated step / bad content type;404if 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_documentsmetadata row only (bytes never touch the DB) and moves the manual step toin_review.404if 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.400on a malformed national id; a vendor fail comes back asstepStatus:"failed"+failureReason(still200).
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 ashared_simsupport alert. On pass setsusers.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_accountsrow → else400. Reuses the b3IBankAccountOwnershipVerifier; the holder national id must equal the verified nurse national id. On match sets the account'smatched_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 }.idnull → create; else update. data:VerificationStepTypeDto.- Notes:
codemust be snake_case ([a-z][a-z0-9_]*) and is immutable once the step-type is in use.400invalid code/labels;404unknownidon update;409duplicatecode.
DELETE api/v1/admin_verification_step_types/{id}
- Purpose: deactivate a step-type (
is_active=false) — never a hard delete. data:bool(success).404unknown 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(defaultin_review) + paginationpage/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.404if 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? }—rejectionReasonrequired whenapprove=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 anurse_credentialsrow —credential_numberENCRYPTED (never serialized);holderNamecross-checked against the verified identity name (mismatch → 400, no credential recorded);criminal_recordrequiresexpiresAt. Writes anaudit_logsdecision record, then re-aggregates the verification (may flipis_verified).400missingrejectionReason/ holder-name mismatch / missing requiredexpiresAt;404step not found.
POST api/v1/admin_verifications/{nurseVerificationId}/suspend
- Purpose: suspend a verified nurse.
- Body:
{ reason }. data:bool.- Notes: sets
status=suspendedand reversesis_verified=0in the same transaction. Writes anaudit_logsrecord.404unknown 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 averification_expiredsupport alert + averification_expiry_promptnotification, and re-gates bookability. The scheduled cron is deferred (configverification_expiry_scan_cadence_hours, int, default24).
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).404for 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_numberis 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.
IShahkarVerifier→MockShahkarVerifier: pass unless the configured shared-SIM phone09120000000(→ shared-SIM handled failure) or the mismatch national id1111111111.IIdentityKycProvider→MockIdentityKycProvider: passes any well-formed 10-digit national id except the configured fail id0000000000.ICredentialVerifier→MockCredentialVerifier: the manual-admin default — alwaysRequiresManualReview/verification_method=manual.- Reused:
IBankAccountOwnershipVerifier(b3; mismatch IBANIR000000000000000000000000→ mismatch),IObjectStorage(b0; local-disk, signed PUT/GET URLs),IFieldEncryptor(b0; encryptscredential_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_methodenums; transactionalis_verifiedflip; encrypted-never-serializedcredential_number; three new mocked vendor seams (IShahkarVerifier,IIdentityKycProvider,ICredentialVerifier). Scheduled expiry cron deferred.
Refinement phase 3 additions (REQ-011)
VerificationStepDtogainsisRequired(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 anino_membership(and, if a license number is sent,moh_competency_license)nurse_credentialsrow (unverified — admin still decides) and persistsspecialtieson the profile. The INO number is encrypted.