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>
10 KiB
Backend Phase 6 — Nurse verification & credentials (mocked vendors) — report
Status: complete · build clean (0 new code warnings) · dotnet test Baya.sln green (153 pass).
Vendors are mocked (three new DI seams + reused b3/b0 seams) — see What is mocked.
What was built
The trust engine the whole marketplace gates on — a data-driven verification pipeline, the admin review
queue, the structured credential registry, the transactional nurse_profiles.is_verified flip, an
admin-triggered credential-expiry scanner, and the public trust badge. One additive migration (new verif
schema, 5 tables), 15 endpoints across 4 controllers, and 3 new mock vendor seams.
- Schema (new
verifschema):nurse_verifications(the header;status= the single source of verification truth),verification_step_types(the seeded catalog of possible steps),verification_steps(one per required step-type per nurse; snapshotsis_automated),verification_documents(metadata only — bytes never in the DB),nurse_credentials(credential_numberencrypted, never serialized).nurse_profiles.is_verifiedis the only derived boolean, flipped only inside the finalize transaction. - Six seeded, stable step-type codes:
identity_kyc,shahkar_match,moh_competency_license,ino_membership,criminal_record,bank_account_verification(three of them credential-bearing). - Nurse pipeline (
NurseVerificationController,[Authorize]+ nurse role in handler, tenancy-scoped):submit(upsert header + seed one step per active required step-type; idempotent),GETstatus (the checklist + aggregate +blockingSteps+isBookable),upload_url/documents(manual steps →in_review), and three automated/runendpoints —identity_kyc(populatesusers.national_id+national_id_verified_at),shahkar_match(requires KYC; shared-SIM = explicit handled failure →shared_simalert; setsshahkar_verified_at),bank_account_verification(reuses the b3IBankAccountOwnershipVerifier; money-mule guard — holder national id must equal the verified nurse national id; on match setsmatched_national_id=1). - Admin step-type catalog (
AdminVerificationStepTypesController, dynamic-permission,sensitiverate-limit): cached list (generation token), upsert (snake_casecode, immutable once in use, dup → 409), deactivate (is_active=false, never hard delete). - Admin review (
AdminVerificationsController, dynamic-permission,sensitiverate-limit): queue (in_reviewdefault; docs carry signed GET URLs), case detail (steps + docs + credentials + identity name),decide(manual steps; on approving a credential-bearing step records an encryptednurse_credentialsrow, holder-name cross-check vs identity → mismatch 400 with no credential,criminal_recordrequiresexpiresAt; writes anaudit_logsrecord; re-aggregates, may flipis_verified),suspend(status=suspended+ reverseis_verified=0in one transaction + audit), andscan_expiring(reverts lapsed steps →expired,verification_expiredalert +verification_expiry_promptnotification, re-gates bookability). - Public trust badge (
NursesController,[AllowAnonymous]):GET nurses/{id}/trust_badgereturnsisVerified+approvedAt+ the credential TYPES held (never numbers); cached (short TTL, evicted on suspension/expiry/decision);404unknown nurse. - The scan logic ships as
ScanExpiringCredentialsCommand; the scheduled cron is deferred — the adminscan_expiringendpoint is the entry point (configverification_expiry_scan_cadence_hours, int, default24).
What is now testable — and exactly how (the phase §7 steps)
Run the API (dotnet run --project src/API/Baya.Web.Api/...) against a reachable SQL Server; use Swagger/curl.
The seam mocks are deterministic — use the test national ids / IBAN / phone below.
- Open the checklist — as a nurse (with a b3 nurse profile),
POST /api/v1/nurse_verification/submit→200;GET /api/v1/nurse_verification→status: "pending",isBookable: false, one seeded step per active required step-type, each with itsisAutomated. - Identity KYC passes —
POST /api/v1/nurse_verification/steps/identity_kyc/run{ "nationalId": "1234567891" }→stepStatus: "passed"; the nurse'susers.national_id+national_id_verified_atare populated. The configured fail id0000000000→stepStatus: "failed"+failureReason(still200). - Shahkar match —
POST /api/v1/nurse_verification/steps/shahkar_match/run(no body) →passed;shahkar_verified_atset. A nurse on the shared-SIM phone09120000000→ handled failure + ashared_simsupport alert row. Running it before KYC → 400. - Bank ownership — with a primary
nurse_bank_accountsrow (b3),POST /api/v1/nurse_verification/steps/bank_account_verification/run→ on match setsmatched_national_id=1; the mismatch IBANIR000000000000000000000000→ failed. No primary account → 400. - Manual document upload — for a credential step,
POST steps/{stepId}/upload_url{ "contentType": "application/pdf" }→{ objectStorageKey, uploadUrl }; PUT the bytes touploadUrl;POST steps/{stepId}/documents{ objectStorageKey, integrityHash, contentType, fileSizeBytes }→ the step moves toin_reviewand a metadata row is stored (no bytes in the DB). An automated step rejects the upload → 400. - Admin review queue — as admin,
GET /api/v1/admin_verifications(defaultstatus=in_review) → the pending step with signed GET URLs on its documents;GET /api/v1/admin_verifications/{id}→ the full case incl. the identity name for cross-check. - Approve a credential —
POST /api/v1/admin_verifications/steps/{stepId}/decide{ approve: true, credentialNumber, holderName: "<verified identity name>", issuingAuthority, issuedAt }→ records an encrypted credential (credentialIdreturned; the number is never serialized back), writes an audit record, re-aggregates. A holder-name mismatch → 400 with no credential recorded; acriminal_recordapproval withoutexpiresAt→ 400;approve:falsewithoutrejectionReason→ 400. - is_verified flips — once every required step is
passed/approved, the re-aggregate flipsnurse_profiles.is_verified=1in one transaction;GET /api/v1/nurse_verificationnow readsstatus: "approved",isBookable: true. - Public trust badge —
GET /api/v1/nurses/{nurseId}/trust_badge→isVerified: true,approvedAt,credentialTypes: ["moh_competency_license", …](types only, never numbers). Unknown nurse → 404. - Suspend & expiry —
POST /api/v1/admin_verifications/{id}/suspend{ reason }setsstatus=suspendedand reversesis_verified=0in one transaction (+ audit); the trust badge updates.POST /api/v1/admin_verifications/scan_expiringreverts lapsed time-limited steps toexpired, raises averification_expiredalert +verification_expiry_promptnotification, and re-gates bookability (ScanExpiringResult{ scannedSteps, revertedNurses }).
What is mocked / waiting on a real service
Three new seams (all deterministic, all mocked here) + three reused seams:
IShahkarVerifier→MockShahkarVerifier— phone↔national-id match: pass unless the shared-SIM phone09120000000(→ shared-SIM handled failure) or the mismatch national id1111111111. Registry row:IShahkarVerifierinmocks-registry.md.IIdentityKycProvider→MockIdentityKycProvider— national-id + liveness: passes any well-formed 10-digit national id except the configured fail id0000000000. Registry row:IIdentityKycProvider.ICredentialVerifier→MockCredentialVerifier— MoH/INO/criminal-record: the manual-admin default (alwaysRequiresManualReview/verification_method=manual). Registry row:ICredentialVerifier.- Reused:
IBankAccountOwnershipVerifier(b3 — mismatch IBANIR000000000000000000000000),IObjectStorage(b0 — local-disk signed PUT/GET URLs),IFieldEncryptor(b0 — encryptscredential_number).
Contracts
- Produced:
dev/contracts/domains/verification.md(all 15 routes, DTO shapes, the four enums, failure cases, side effects, mock test values, worked example).swagger.v1.jsonrefreshed —dev/contracts/openapi/swagger.v1.jsonincludes all 15 b6 endpoints. - Consumed:
nurse_profiles+IBankAccountOwnershipVerifier(b3),users.national_id/shahkar_verified_at(b2),IObjectStorage/IFieldEncryptor/ICacheService/ CQRS /OperationResult/BaseController(b0), dynamic-permission +support_alerts/notifications/audit_logs+sensitiverate-limit (b1).
Docs updated
- New contract
dev/contracts/domains/verification.md; openapi snapshot refreshed. - Handoff
backend/handoff/after-backend-phase-6.md; status logbackend/STATUS.md(append). - The three b6 seams (
IShahkarVerifier,IIdentityKycProvider,ICredentialVerifier) are the pre-listed rows inreports/mocks-registry.md— now realized as seam + fake impl.
Follow-ups for later phases
- Scheduled expiry cron — a
CredentialExpiryScannerJobhosted scheduler that calls the shippedScanExpiringCredentialsCommandonverification_expiry_scan_cadence_hourscadence. Deferred — the adminscan_expiringendpoint is the manual entry point today. - Automated MoH/INO license lookup — behind
ICredentialVerifier(verification_method=api) once a portal exists. Deferred. fraud_flags/ ML fraud scoring — deferred.- Professional-liability-insurance step — addable later as a
verification_step_typesrow (no schema change). Deferred. - b7 (search & matching) — reads
nurse_profiles.is_verifiedfornurse_search_index.is_searchable; b6 owns the flip, b7 owns the index.