951 lines
25 KiB
JavaScript
951 lines
25 KiB
JavaScript
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.
|
|
}
|
|
}
|