import { clientFetch } from '@/lib/api/client'; import { ApiError } from '@/lib/api/errors'; import { unwrap, type ApiEnvelope, type Paginated, type PageParams } from '@/lib/api/types'; import { ADMIN_QUEUE_PAGE_SIZE } from '../constants'; import type { AdminVerificationCase, AdminVerificationQueueFilters, AdminVerificationQueueItem, AdminVerificationQueuePage, AdminVerificationStepDetail, CredentialDetailsInput, DecideStepInput, DecideStepResult, DocumentConfirmedResult, IdentityKycInput, NurseCredential, RunStepResult, TrustBadge, UploadUrlResult, VerificationAggregateStatus, VerificationApi, VerificationDocument, VerificationStatus, VerificationStepStatus, } from '../types'; const BASE = '/api/v1/nurse_verification'; const NURSES_BASE = '/api/v1/nurses'; const ADMIN_BASE = '/api/v1/admin_verifications'; /** `AdminPendingStepDto` — one **row per step** awaiting attention (not per nurse); documents carry signed GET URLs. */ interface AdminPendingStepWire { nurseVerificationId: number; nurseId: number; nurseName: string; stepId: number; stepCode: string; stepDisplayName: string; status: VerificationStepStatus; submittedAt: string | null; documents: VerificationDocument[]; } /** `AdminStepDetailDto` — note the id lives under `stepId` on the wire (mapped to `id`). */ interface AdminStepDetailWire { stepId: number; code: string; displayName: string; status: VerificationStepStatus; isAutomated: boolean; expiresAt: string | null; failureReason: string | null; documents: VerificationDocument[]; } /** `AdminVerificationDetailDto`. */ interface AdminVerificationDetailWire { nurseVerificationId: number; nurseId: number; identityName: string; status: VerificationAggregateStatus; steps: AdminStepDetailWire[]; credentials: NurseCredential[]; } /** * REQ-034: the queue DTO is **per step**, so we fold rows to one item per nurse for the queue UI. This is * lossy — the per-step page carries no whole-nurse aggregate (`stepsPassed`/`stepsTotal`/expiry), and a * nurse's steps can straddle page boundaries — which is why a nurse-level queue endpoint is filed. We map * what the row gives (nurse identity, the step as `nextPendingStepCode`, `submittedAt`) and leave the * unavailable aggregate fields at neutral defaults. */ function foldQueueRows(rows: AdminPendingStepWire[]): AdminVerificationQueueItem[] { const byNurse = new Map(); for (const row of rows) { const existing = byNurse.get(row.nurseVerificationId); if (existing) { if (row.submittedAt && (existing.submittedAt == null || row.submittedAt < existing.submittedAt)) { existing.submittedAt = row.submittedAt; } continue; } byNurse.set(row.nurseVerificationId, { nurseVerificationId: row.nurseVerificationId, nurseId: row.nurseId, nurseName: row.nurseName, status: 'in_review', stepsPassed: 0, stepsTotal: 0, nextPendingStepCode: row.stepCode, submittedAt: row.submittedAt, hasExpiringCredential: false, }); } return Array.from(byNurse.values()); } function toStepDetail(wire: AdminStepDetailWire): AdminVerificationStepDetail { return { id: wire.stepId, code: wire.code, displayName: wire.displayName, status: wire.status, isAutomated: wire.isAutomated, expiresAt: wire.expiresAt, failureReason: wire.failureReason, documents: wire.documents, }; } /** * Computes the browser-side integrity hash the confirm endpoint records against the uploaded bytes * (SHA-256 hex). Runs in the browser only (Web Crypto); the mock skips it. */ async function sha256Hex(file: File): Promise { const buffer = await file.arrayBuffer(); const digest = await crypto.subtle.digest('SHA-256', buffer); return Array.from(new Uint8Array(digest)) .map((byte) => byte.toString(16).padStart(2, '0')) .join(''); } /** * PUTs the file bytes to the signed object-storage URL with upload **progress**. This is a direct PUT * to `IObjectStorage` (not our API), so it uses XHR — `fetch` can't report upload progress and the * signed URL needs no bearer. The bearer-carrying JSON calls still go through `clientFetch`. */ function putSignedUrl(uploadUrl: string, file: File, onProgress?: (percent: number) => void): Promise { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('PUT', uploadUrl); xhr.setRequestHeader('Content-Type', file.type); xhr.upload.onprogress = (event) => { if (event.lengthComputable) onProgress?.(Math.round((event.loaded / event.total) * 100)); }; xhr.onload = () => xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new ApiError(xhr.status, 'Object storage upload failed', 'upload_failed')); xhr.onerror = () => reject(new ApiError(0, 'Network error during upload', 'network_error')); xhr.send(file); }); } /** * Real HTTP implementation of the VerificationApi seam (b6 contract). Routes are action-style + * snake_case; JSON bodies/fields are camelCase; step ids come from the route. Automated-step failures * come back as `200` with `stepStatus:"failed"` — surfaced, not thrown. Selected once * USE_VERIFICATION_MOCK is false. * * Gap: `submitCredentialDetails` has no nurse-facing b6 endpoint (admin enters the structured fields on * review) — it is filed in `for-backend.md` and no-ops here; the document uploads it accompanies ARE * contract-backed (upload_url → PUT → documents). The mock persists the details for the standalone demo. */ export const verificationClientApi: VerificationApi = { getStatus: async () => unwrap(await clientFetch>(BASE)), start: async () => unwrap(await clientFetch>(`${BASE}/submit`, { method: 'POST' })), runIdentityKyc: async ({ nationalId, livenessCaptured }: IdentityKycInput) => unwrap( await clientFetch>(`${BASE}/steps/identity_kyc/run`, { method: 'POST', body: JSON.stringify({ nationalId, livenessPayload: livenessCaptured ? 'captured' : null }), }), ), runShahkarMatch: async () => unwrap(await clientFetch>(`${BASE}/steps/shahkar_match/run`, { method: 'POST' })), runBankVerification: async () => unwrap( await clientFetch>(`${BASE}/steps/bank_account_verification/run`, { method: 'POST', }), ), uploadStepDocument: async (stepId, file, onProgress): Promise => { const { objectStorageKey, uploadUrl } = unwrap( await clientFetch>(`${BASE}/steps/${stepId}/upload_url`, { method: 'POST', body: JSON.stringify({ contentType: file.type, fileName: file.name }), }), ); await putSignedUrl(uploadUrl, file, onProgress); const integrityHash = await sha256Hex(file); const confirmed = unwrap( await clientFetch>(`${BASE}/steps/${stepId}/documents`, { method: 'POST', body: JSON.stringify({ objectStorageKey, integrityHash, contentType: file.type, fileSizeBytes: file.size, originalFileName: file.name, }), }), ); return { id: confirmed.documentId, contentType: file.type, fileSizeBytes: file.size, originalFileName: file.name, url: '', }; }, // No nurse-facing endpoint yet (see gap note above / for-backend.md); the accompanying document // uploads carry the real signal. Kept as a seam method so the mock can persist details unchanged. submitCredentialDetails: async (_input: CredentialDetailsInput) => {}, getTrustBadge: async (nurseId) => unwrap(await clientFetch>(`${NURSES_BASE}/${nurseId}/trust_badge`)), listVerificationQueue: async ( filters: AdminVerificationQueueFilters, params: PageParams, ): Promise => { const query = new URLSearchParams(); if (filters.status) query.set('status', filters.status); // REQ-062: proposed `q` search param — the server ignores it today (no-op, never a 400) until the // endpoint gains the filter; the client sends it so the swap is a no-op once it lands. if (filters.search) query.set('q', filters.search); query.set('page', String(params.page ?? 1)); query.set('page_size', String(params.pageSize ?? ADMIN_QUEUE_PAGE_SIZE)); const page = unwrap( await clientFetch>>(`${ADMIN_BASE}?${query.toString()}`), ); // REQ-034: `total`/`page`/`pageSize` stay the wire (per-step) values until a nurse-level queue endpoint // exists — folding to one item per nurse (see foldQueueRows) makes the count nominal, not exact. // REQ-062: `counts` stays undefined on the real path — the queue screen renders the status tabs // without badge counts until the endpoint serves the whole-desk totals. return { items: foldQueueRows(page.items), total: page.total, page: page.page, pageSize: page.pageSize }; }, getVerificationCase: async (nurseVerificationId: number): Promise => { const wire = unwrap( await clientFetch>(`${ADMIN_BASE}/${nurseVerificationId}`), ); return { nurseVerificationId: wire.nurseVerificationId, nurseId: wire.nurseId, identityName: wire.identityName, status: wire.status, steps: wire.steps.map(toStepDetail), credentials: wire.credentials, }; }, decideStep: async (stepId: number, input: DecideStepInput): Promise => unwrap( await clientFetch>(`${ADMIN_BASE}/steps/${stepId}/decide`, { method: 'POST', body: JSON.stringify(input), }), ), // Phase 09: explicit whole-verification approve/reject actions (AdminVerificationsController.Approve/ // Reject). Approve re-confirms what Finalize already flipped once every step passed; reject is a distinct // admin override, not a per-step decision. approveVerification: async (nurseVerificationId: number): Promise => { await clientFetch>(`${ADMIN_BASE}/${nurseVerificationId}/approve`, { method: 'POST' }); }, rejectVerification: async (nurseVerificationId: number, reason: string): Promise => { await clientFetch>(`${ADMIN_BASE}/${nurseVerificationId}/reject`, { method: 'POST', body: JSON.stringify({ reason }), }); }, };