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

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_verified flip, 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 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:trueidentity_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 code409.
  • 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.

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: VerificationStatusDtostatus, 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: TrustBadgeDtoisVerified, 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.

  • IShahkarVerifierMockShahkarVerifier: pass unless the configured shared-SIM phone 09120000000 (→ shared-SIM handled failure) or the mismatch national id 1111111111.
  • IIdentityKycProviderMockIdentityKycProvider: passes any well-formed 10-digit national id except the configured fail id 0000000000.
  • ICredentialVerifierMockCredentialVerifier: 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.