import { AppError } from '../lib/errors.js'; import { isDatabaseConfigured, query, withTransaction } from './db.js'; import { loadActorContext, requireTenantContext } from './actor-context.js'; import { invokeVertexMultimodalModel } from './llm.js'; export const VerificationStatus = Object.freeze({ PENDING: 'PENDING', PROCESSING: 'PROCESSING', AUTO_PASS: 'AUTO_PASS', AUTO_FAIL: 'AUTO_FAIL', NEEDS_REVIEW: 'NEEDS_REVIEW', APPROVED: 'APPROVED', REJECTED: 'REJECTED', ERROR: 'ERROR', }); const HUMAN_TERMINAL_STATUSES = new Set([ VerificationStatus.APPROVED, VerificationStatus.REJECTED, ]); const memoryVerificationJobs = new Map(); function useMemoryStore() { if (process.env.VERIFICATION_STORE === 'memory') { return true; } return !isDatabaseConfigured() && (process.env.NODE_ENV === 'test' || process.env.AUTH_BYPASS === 'true'); } function nextVerificationId() { if (typeof crypto?.randomUUID === 'function') { return crypto.randomUUID(); } return `verification_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; } function loadMemoryJob(verificationId) { const job = memoryVerificationJobs.get(verificationId); if (!job) { throw new AppError('NOT_FOUND', 'Verification not found', 404, { verificationId, }); } return job; } async function processVerificationJobInMemory(verificationId) { const job = memoryVerificationJobs.get(verificationId); if (!job || job.status !== VerificationStatus.PENDING) { return; } job.status = VerificationStatus.PROCESSING; job.updated_at = new Date().toISOString(); memoryVerificationJobs.set(verificationId, job); const workItem = { id: job.id, type: job.type, fileUri: job.file_uri, subjectType: job.subject_type, subjectId: job.subject_id, rules: job.metadata?.rules || {}, metadata: job.metadata || {}, }; try { const result = workItem.type === 'attire' ? await runAttireChecks(workItem) : await runThirdPartyChecks(workItem, workItem.type); const updated = { ...job, status: result.status, confidence: result.confidence, reasons: result.reasons || [], extracted: result.extracted || {}, provider_name: result.provider?.name || null, provider_reference: result.provider?.reference || null, updated_at: new Date().toISOString(), }; memoryVerificationJobs.set(verificationId, updated); } catch (error) { const updated = { ...job, status: VerificationStatus.ERROR, reasons: [error?.message || 'Verification processing failed'], provider_name: 'verification-worker', provider_reference: `error:${error?.code || 'unknown'}`, updated_at: new Date().toISOString(), }; memoryVerificationJobs.set(verificationId, updated); } } function accessMode() { const mode = `${process.env.VERIFICATION_ACCESS_MODE || 'tenant'}`.trim().toLowerCase(); if (mode === 'owner' || mode === 'tenant' || mode === 'authenticated') { return mode; } return 'tenant'; } function providerTimeoutMs() { return Number.parseInt(process.env.VERIFICATION_PROVIDER_TIMEOUT_MS || '8000', 10); } function attireModel() { return process.env.VERIFICATION_ATTIRE_MODEL || 'gemini-2.0-flash-lite-001'; } function clampConfidence(value, fallback = 0.5) { const parsed = Number(value); if (!Number.isFinite(parsed)) return fallback; if (parsed < 0) return 0; if (parsed > 1) return 1; return parsed; } function asReasonList(reasons, fallback) { if (Array.isArray(reasons) && reasons.length > 0) { return reasons.map((item) => `${item}`); } return [fallback]; } function normalizeMachineStatus(status) { if ( status === VerificationStatus.AUTO_PASS || status === VerificationStatus.AUTO_FAIL || status === VerificationStatus.NEEDS_REVIEW ) { return status; } return VerificationStatus.NEEDS_REVIEW; } function toPublicJob(row) { if (!row) return null; return { verificationId: row.id, type: row.type, subjectType: row.subject_type, subjectId: row.subject_id, fileUri: row.file_uri, status: row.status, confidence: row.confidence == null ? null : Number(row.confidence), reasons: Array.isArray(row.reasons) ? row.reasons : [], extracted: row.extracted || {}, provider: row.provider_name ? { name: row.provider_name, reference: row.provider_reference || null, } : null, review: row.review || {}, createdAt: row.created_at, updatedAt: row.updated_at, }; } async function assertAccess(row, actorUid) { if (row.owner_user_id === actorUid) { return; } const mode = accessMode(); if (mode === 'authenticated') { return; } if (mode === 'owner' || !row.tenant_id) { throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403, { verificationId: row.id, }); } const actorContext = await loadActorContext(actorUid); if (actorContext.tenant?.tenantId !== row.tenant_id) { throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403, { verificationId: row.id, }); } } async function loadJob(verificationId) { const result = await query( ` SELECT * FROM verification_jobs WHERE id = $1 `, [verificationId] ); if (result.rowCount === 0) { throw new AppError('NOT_FOUND', 'Verification not found', 404, { verificationId, }); } return result.rows[0]; } async function appendVerificationEvent(client, { verificationJobId, fromStatus, toStatus, actorType, actorId, details = {}, }) { await client.query( ` INSERT INTO verification_events ( verification_job_id, from_status, to_status, actor_type, actor_id, details ) VALUES ($1, $2, $3, $4, $5, $6::jsonb) `, [verificationJobId, fromStatus, toStatus, actorType, actorId, JSON.stringify(details)] ); } function normalizeArtifactStatus(status) { switch (`${status || ''}`.toUpperCase()) { case VerificationStatus.AUTO_PASS: case VerificationStatus.APPROVED: return 'VERIFIED'; case VerificationStatus.AUTO_FAIL: case VerificationStatus.REJECTED: return 'REJECTED'; case VerificationStatus.PENDING: case VerificationStatus.PROCESSING: case VerificationStatus.NEEDS_REVIEW: case VerificationStatus.ERROR: default: return 'PENDING'; } } function looksLikeUuid(value) { return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(`${value || ''}`); } async function syncVerificationSubjectStatus(client, job) { const subjectType = job.subject_type || job.subjectType || null; const subjectId = job.subject_id || job.subjectId || null; const tenantId = job.tenant_id || job.tenantId || null; const staffId = job.staff_id || job.staffId || null; const verificationId = job.id || job.verificationId || null; if (!subjectType || !subjectId || !tenantId || !staffId || !verificationId) { return; } const nextStatus = normalizeArtifactStatus(job.status); const metadataPatch = JSON.stringify({ verificationStatus: job.status, verificationJobId: verificationId, syncedFromVerification: true, }); const subjectIdIsUuid = looksLikeUuid(subjectId); if (subjectType === 'staff_document' || subjectType === 'attire_item' || (subjectType === 'worker' && subjectIdIsUuid)) { await client.query( ` UPDATE staff_documents SET status = $4, metadata = COALESCE(metadata, '{}'::jsonb) || $5::jsonb, updated_at = NOW() WHERE tenant_id = $1 AND staff_id = $2 AND document_id::text = $3 `, [tenantId, staffId, subjectId, nextStatus, metadataPatch] ); return; } if (subjectType === 'certificate' || (subjectType === 'worker' && !subjectIdIsUuid)) { await client.query( ` UPDATE certificates SET status = $4, metadata = COALESCE(metadata, '{}'::jsonb) || $5::jsonb, updated_at = NOW() WHERE tenant_id = $1 AND staff_id = $2 AND certificate_type = $3 `, [tenantId, staffId, subjectId, nextStatus, metadataPatch] ); } } async function runAttireChecks(job) { if (process.env.VERIFICATION_ATTIRE_AUTOPASS === 'true') { return { status: VerificationStatus.AUTO_PASS, confidence: 0.8, reasons: ['Auto-pass mode enabled for attire in dev'], extracted: { expected: job.rules, }, provider: { name: 'attire-auto-pass', reference: null, }, }; } const attireProvider = process.env.VERIFICATION_ATTIRE_PROVIDER || 'vertex'; if (attireProvider !== 'vertex') { return { status: VerificationStatus.NEEDS_REVIEW, confidence: 0.45, reasons: [`Attire provider '${attireProvider}' is not supported`], extracted: { expected: job.rules, }, provider: { name: attireProvider, reference: null, }, }; } try { const prompt = [ 'You are validating worker attire evidence.', `Rules: ${JSON.stringify(job.rules || {})}`, 'Return AUTO_PASS only when the image clearly matches required attire.', 'Return AUTO_FAIL when the image clearly violates required attire.', 'Return NEEDS_REVIEW when uncertain.', ].join('\n'); const schema = { type: 'object', properties: { status: { type: 'string' }, confidence: { type: 'number' }, reasons: { type: 'array', items: { type: 'string' }, }, extracted: { type: 'object', additionalProperties: true, }, }, required: ['status', 'confidence', 'reasons'], }; const modelOutput = await invokeVertexMultimodalModel({ prompt, responseJsonSchema: schema, fileUris: [job.fileUri], model: attireModel(), timeoutMs: providerTimeoutMs(), }); const result = modelOutput?.result || {}; return { status: normalizeMachineStatus(result.status), confidence: clampConfidence(result.confidence, 0.6), reasons: asReasonList(result.reasons, 'Attire check completed'), extracted: result.extracted || {}, provider: { name: 'vertex-attire', reference: modelOutput?.model || attireModel(), }, }; } catch (error) { return { status: VerificationStatus.NEEDS_REVIEW, confidence: 0.35, reasons: ['Automatic attire check unavailable, manual review required'], extracted: {}, provider: { name: 'vertex-attire', reference: `error:${error?.code || 'unknown'}`, }, }; } } function getProviderConfig(type) { if (type === 'government_id') { return { name: 'government-id-provider', url: process.env.VERIFICATION_GOV_ID_PROVIDER_URL, token: process.env.VERIFICATION_GOV_ID_PROVIDER_TOKEN, }; } if (type === 'tax_form') { return { name: 'tax-form-provider', url: process.env.VERIFICATION_TAX_FORM_PROVIDER_URL, token: process.env.VERIFICATION_TAX_FORM_PROVIDER_TOKEN, }; } return { name: 'certification-provider', url: process.env.VERIFICATION_CERT_PROVIDER_URL, token: process.env.VERIFICATION_CERT_PROVIDER_TOKEN, }; } async function runThirdPartyChecks(job, type) { const provider = getProviderConfig(type); if (!provider.url) { return { status: VerificationStatus.NEEDS_REVIEW, confidence: 0.4, reasons: [`${provider.name} is not configured`], extracted: {}, provider: { name: provider.name, reference: null, }, }; } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), providerTimeoutMs()); try { const response = await fetch(provider.url, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(provider.token ? { Authorization: `Bearer ${provider.token}` } : {}), }, body: JSON.stringify({ type, subjectType: job.subjectType, subjectId: job.subjectId, fileUri: job.fileUri, rules: job.rules, metadata: job.metadata, }), signal: controller.signal, }); const payload = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(payload?.error || payload?.message || `${provider.name} failed`); } return { status: normalizeMachineStatus(payload.status), confidence: clampConfidence(payload.confidence, 0.6), reasons: asReasonList(payload.reasons, `${provider.name} completed`), extracted: payload.extracted || {}, provider: { name: provider.name, reference: payload.reference || null, }, }; } catch (error) { return { status: VerificationStatus.NEEDS_REVIEW, confidence: 0.35, reasons: [error?.message || `${provider.name} unavailable`], extracted: {}, provider: { name: provider.name, reference: null, }, }; } finally { clearTimeout(timeout); } } async function processVerificationJob(verificationId) { const startedJob = await withTransaction(async (client) => { const result = await client.query( ` SELECT * FROM verification_jobs WHERE id = $1 FOR UPDATE `, [verificationId] ); if (result.rowCount === 0) { return null; } const job = result.rows[0]; if (job.status !== VerificationStatus.PENDING) { return null; } await client.query( ` UPDATE verification_jobs SET status = $2, updated_at = NOW() WHERE id = $1 `, [verificationId, VerificationStatus.PROCESSING] ); await appendVerificationEvent(client, { verificationJobId: verificationId, fromStatus: job.status, toStatus: VerificationStatus.PROCESSING, actorType: 'worker', actorId: 'verification-worker', }); return { id: verificationId, type: job.type, fileUri: job.file_uri, subjectType: job.subject_type, subjectId: job.subject_id, rules: job.metadata?.rules || {}, metadata: job.metadata || {}, }; }); if (!startedJob) { return; } try { const result = startedJob.type === 'attire' ? await runAttireChecks(startedJob) : await runThirdPartyChecks(startedJob, startedJob.type); await withTransaction(async (client) => { const updated = await client.query( ` UPDATE verification_jobs SET status = $2, confidence = $3, reasons = $4::jsonb, extracted = $5::jsonb, provider_name = $6, provider_reference = $7, updated_at = NOW() WHERE id = $1 RETURNING * `, [ verificationId, result.status, result.confidence, JSON.stringify(result.reasons || []), JSON.stringify(result.extracted || {}), result.provider?.name || null, result.provider?.reference || null, ] ); await syncVerificationSubjectStatus(client, updated.rows[0]); await appendVerificationEvent(client, { verificationJobId: verificationId, fromStatus: VerificationStatus.PROCESSING, toStatus: result.status, actorType: 'worker', actorId: 'verification-worker', details: { confidence: result.confidence, }, }); }); } catch (error) { await withTransaction(async (client) => { const updated = await client.query( ` UPDATE verification_jobs SET status = $2, reasons = $3::jsonb, provider_name = 'verification-worker', provider_reference = $4, updated_at = NOW() WHERE id = $1 RETURNING * `, [ verificationId, VerificationStatus.ERROR, JSON.stringify([error?.message || 'Verification processing failed']), `error:${error?.code || 'unknown'}`, ] ); await syncVerificationSubjectStatus(client, updated.rows[0]); await appendVerificationEvent(client, { verificationJobId: verificationId, fromStatus: VerificationStatus.PROCESSING, toStatus: VerificationStatus.ERROR, actorType: 'worker', actorId: 'verification-worker', details: { error: error?.message || 'Verification processing failed', }, }); }); } } function queueVerificationProcessing(verificationId) { setImmediate(() => { const worker = useMemoryStore() ? processVerificationJobInMemory : processVerificationJob; worker(verificationId).catch(() => {}); }); } export async function createVerificationJob({ actorUid, payload }) { if (useMemoryStore()) { const timestamp = new Date().toISOString(); const created = { id: nextVerificationId(), tenant_id: null, staff_id: null, owner_user_id: actorUid, type: payload.type, subject_type: payload.subjectType || null, subject_id: payload.subjectId || null, file_uri: payload.fileUri, status: VerificationStatus.PENDING, confidence: null, reasons: [], extracted: {}, provider_name: null, provider_reference: null, review: {}, metadata: { ...(payload.metadata || {}), rules: payload.rules || {}, }, created_at: timestamp, updated_at: timestamp, }; memoryVerificationJobs.set(created.id, created); queueVerificationProcessing(created.id); return toPublicJob(created); } const context = await requireTenantContext(actorUid); const created = await withTransaction(async (client) => { const result = await client.query( ` INSERT INTO verification_jobs ( tenant_id, staff_id, document_id, owner_user_id, type, subject_type, subject_id, file_uri, status, reasons, extracted, review, metadata ) VALUES ( $1, $2, NULL, $3, $4, $5, $6, $7, 'PENDING', '[]'::jsonb, '{}'::jsonb, '{}'::jsonb, $8::jsonb ) RETURNING * `, [ context.tenant.tenantId, context.staff?.staffId || null, actorUid, payload.type, payload.subjectType || null, payload.subjectId || null, payload.fileUri, JSON.stringify({ ...(payload.metadata || {}), rules: payload.rules || {}, }), ] ); await appendVerificationEvent(client, { verificationJobId: result.rows[0].id, fromStatus: null, toStatus: VerificationStatus.PENDING, actorType: 'system', actorId: actorUid, }); return result.rows[0]; }); queueVerificationProcessing(created.id); return toPublicJob(created); } export async function getVerificationJob(verificationId, actorUid) { if (useMemoryStore()) { const job = loadMemoryJob(verificationId); await assertAccess(job, actorUid); return toPublicJob(job); } const job = await loadJob(verificationId); await assertAccess(job, actorUid); return toPublicJob(job); } export async function reviewVerificationJob(verificationId, actorUid, review) { if (useMemoryStore()) { const job = loadMemoryJob(verificationId); await assertAccess(job, actorUid); if (HUMAN_TERMINAL_STATUSES.has(job.status)) { throw new AppError('CONFLICT', 'Verification already finalized', 409, { verificationId, status: job.status, }); } const reviewPayload = { decision: review.decision, reviewedBy: actorUid, reviewedAt: new Date().toISOString(), note: review.note || '', reasonCode: review.reasonCode || 'MANUAL_REVIEW', }; const updated = { ...job, status: review.decision, review: reviewPayload, updated_at: new Date().toISOString(), }; memoryVerificationJobs.set(verificationId, updated); return toPublicJob(updated); } const context = await requireTenantContext(actorUid); const updated = await withTransaction(async (client) => { const result = await client.query( ` SELECT * FROM verification_jobs WHERE id = $1 FOR UPDATE `, [verificationId] ); if (result.rowCount === 0) { throw new AppError('NOT_FOUND', 'Verification not found', 404, { verificationId }); } const job = result.rows[0]; await assertAccess(job, actorUid); if (HUMAN_TERMINAL_STATUSES.has(job.status)) { throw new AppError('CONFLICT', 'Verification already finalized', 409, { verificationId, status: job.status, }); } const reviewPayload = { decision: review.decision, reviewedBy: actorUid, reviewedAt: new Date().toISOString(), note: review.note || '', reasonCode: review.reasonCode || 'MANUAL_REVIEW', }; const updatedResult = await client.query( ` UPDATE verification_jobs SET status = $2, review = $3::jsonb, updated_at = NOW() WHERE id = $1 RETURNING * `, [verificationId, review.decision, JSON.stringify(reviewPayload)] ); await syncVerificationSubjectStatus(client, updatedResult.rows[0]); await client.query( ` INSERT INTO verification_reviews ( verification_job_id, reviewer_user_id, decision, note, reason_code ) VALUES ($1, $2, $3, $4, $5) `, [verificationId, actorUid, review.decision, review.note || null, review.reasonCode || 'MANUAL_REVIEW'] ); await appendVerificationEvent(client, { verificationJobId: verificationId, fromStatus: job.status, toStatus: review.decision, actorType: 'reviewer', actorId: actorUid, details: { reasonCode: review.reasonCode || 'MANUAL_REVIEW', }, }); return { ...job, status: review.decision, review: reviewPayload, updated_at: new Date().toISOString(), }; }); void context; return toPublicJob(updated); } export async function retryVerificationJob(verificationId, actorUid) { if (useMemoryStore()) { const job = loadMemoryJob(verificationId); await assertAccess(job, actorUid); if (job.status === VerificationStatus.PROCESSING) { throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, { verificationId, }); } const updated = { ...job, status: VerificationStatus.PENDING, confidence: null, reasons: [], extracted: {}, provider_name: null, provider_reference: null, review: {}, updated_at: new Date().toISOString(), }; memoryVerificationJobs.set(verificationId, updated); queueVerificationProcessing(verificationId); return toPublicJob(updated); } const updated = await withTransaction(async (client) => { const result = await client.query( ` SELECT * FROM verification_jobs WHERE id = $1 FOR UPDATE `, [verificationId] ); if (result.rowCount === 0) { throw new AppError('NOT_FOUND', 'Verification not found', 404, { verificationId }); } const job = result.rows[0]; await assertAccess(job, actorUid); if (job.status === VerificationStatus.PROCESSING) { throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, { verificationId, }); } const updatedResult = await client.query( ` UPDATE verification_jobs SET status = $2, confidence = NULL, reasons = '[]'::jsonb, extracted = '{}'::jsonb, provider_name = NULL, provider_reference = NULL, review = '{}'::jsonb, updated_at = NOW() WHERE id = $1 RETURNING * `, [verificationId, VerificationStatus.PENDING] ); await syncVerificationSubjectStatus(client, updatedResult.rows[0]); await appendVerificationEvent(client, { verificationJobId: verificationId, fromStatus: job.status, toStatus: VerificationStatus.PENDING, actorType: 'reviewer', actorId: actorUid, details: { retried: true, }, }); return { ...job, status: VerificationStatus.PENDING, confidence: null, reasons: [], extracted: {}, provider_name: null, provider_reference: null, review: {}, updated_at: new Date().toISOString(), }; }); queueVerificationProcessing(verificationId); return toPublicJob(updated); } export async function __resetVerificationJobsForTests() { if (process.env.NODE_ENV !== 'test' && process.env.AUTH_BYPASS !== 'true') { return; } memoryVerificationJobs.clear(); try { await query('DELETE FROM verification_reviews'); await query('DELETE FROM verification_events'); await query('DELETE FROM verification_jobs'); } catch { // Intentionally ignore when tests run without a configured database. } }