feat(api): complete unified v2 mobile surface

This commit is contained in:
zouantchaw
2026-03-13 17:02:24 +01:00
parent 817a39e305
commit b455455a49
39 changed files with 7726 additions and 506 deletions

View File

@@ -1,9 +1,8 @@
import crypto from 'node:crypto';
import { AppError } from '../lib/errors.js';
import { isDatabaseConfigured, query, withTransaction } from './db.js';
import { requireTenantContext } from './actor-context.js';
import { invokeVertexMultimodalModel } from './llm.js';
const jobs = new Map();
export const VerificationStatus = Object.freeze({
PENDING: 'PENDING',
PROCESSING: 'PROCESSING',
@@ -15,82 +14,96 @@ export const VerificationStatus = Object.freeze({
ERROR: 'ERROR',
});
const MACHINE_TERMINAL_STATUSES = new Set([
VerificationStatus.AUTO_PASS,
VerificationStatus.AUTO_FAIL,
VerificationStatus.NEEDS_REVIEW,
VerificationStatus.ERROR,
]);
const HUMAN_TERMINAL_STATUSES = new Set([
VerificationStatus.APPROVED,
VerificationStatus.REJECTED,
]);
function nowIso() {
return new Date().toISOString();
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() {
return process.env.VERIFICATION_ACCESS_MODE || 'authenticated';
}
function eventRecord({ fromStatus, toStatus, actorType, actorId, details = {} }) {
return {
id: crypto.randomUUID(),
fromStatus,
toStatus,
actorType,
actorId,
details,
createdAt: nowIso(),
};
function providerTimeoutMs() {
return Number.parseInt(process.env.VERIFICATION_PROVIDER_TIMEOUT_MS || '8000', 10);
}
function toPublicJob(job) {
return {
verificationId: job.id,
type: job.type,
subjectType: job.subjectType,
subjectId: job.subjectId,
fileUri: job.fileUri,
status: job.status,
confidence: job.confidence,
reasons: job.reasons,
extracted: job.extracted,
provider: job.provider,
review: job.review,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
};
}
function assertAccess(job, actorUid) {
if (accessMode() === 'authenticated') {
return;
}
if (job.ownerUid !== actorUid) {
throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403);
}
}
function requireJob(id) {
const job = jobs.get(id);
if (!job) {
throw new AppError('NOT_FOUND', 'Verification not found', 404, { verificationId: id });
}
return job;
}
function normalizeMachineStatus(status) {
if (
status === VerificationStatus.AUTO_PASS
|| status === VerificationStatus.AUTO_FAIL
|| status === VerificationStatus.NEEDS_REVIEW
) {
return status;
}
return VerificationStatus.NEEDS_REVIEW;
function attireModel() {
return process.env.VERIFICATION_ATTIRE_MODEL || 'gemini-2.0-flash-lite-001';
}
function clampConfidence(value, fallback = 0.5) {
@@ -108,12 +121,89 @@ function asReasonList(reasons, fallback) {
return [fallback];
}
function providerTimeoutMs() {
return Number.parseInt(process.env.VERIFICATION_PROVIDER_TIMEOUT_MS || '8000', 10);
function normalizeMachineStatus(status) {
if (
status === VerificationStatus.AUTO_PASS
|| status === VerificationStatus.AUTO_FAIL
|| status === VerificationStatus.NEEDS_REVIEW
) {
return status;
}
return VerificationStatus.NEEDS_REVIEW;
}
function attireModel() {
return process.env.VERIFICATION_ATTIRE_MODEL || 'gemini-2.0-flash-lite-001';
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,
};
}
function assertAccess(row, actorUid) {
if (accessMode() === 'authenticated') {
return;
}
if (row.owner_user_id !== actorUid) {
throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403);
}
}
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)]
);
}
async function runAttireChecks(job) {
@@ -258,47 +348,26 @@ async function runThirdPartyChecks(job, type) {
signal: controller.signal,
});
const bodyText = await response.text();
let body = {};
try {
body = bodyText ? JSON.parse(bodyText) : {};
} catch {
body = {};
}
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
return {
status: VerificationStatus.NEEDS_REVIEW,
confidence: 0.35,
reasons: [`${provider.name} returned ${response.status}`],
extracted: {},
provider: {
name: provider.name,
reference: body?.reference || null,
},
};
throw new Error(payload?.error || payload?.message || `${provider.name} failed`);
}
return {
status: normalizeMachineStatus(body.status),
confidence: clampConfidence(body.confidence, 0.6),
reasons: asReasonList(body.reasons, `${provider.name} completed check`),
extracted: body.extracted || {},
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: body.reference || null,
reference: payload.reference || null,
},
};
} catch (error) {
const isAbort = error?.name === 'AbortError';
return {
status: VerificationStatus.NEEDS_REVIEW,
confidence: 0.3,
reasons: [
isAbort
? `${provider.name} timeout, manual review required`
: `${provider.name} unavailable, manual review required`,
],
confidence: 0.35,
reasons: [error?.message || `${provider.name} unavailable`],
extracted: {},
provider: {
name: provider.name,
@@ -310,201 +379,462 @@ async function runThirdPartyChecks(job, type) {
}
}
async function runMachineChecks(job) {
if (job.type === 'attire') {
return runAttireChecks(job);
}
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 (job.type === 'government_id') {
return runThirdPartyChecks(job, 'government_id');
}
if (result.rowCount === 0) {
return null;
}
return runThirdPartyChecks(job, 'certification');
}
const job = result.rows[0];
if (job.status !== VerificationStatus.PENDING) {
return null;
}
async function processVerificationJob(id) {
const job = requireJob(id);
if (job.status !== VerificationStatus.PENDING) {
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;
}
const beforeProcessing = job.status;
job.status = VerificationStatus.PROCESSING;
job.updatedAt = nowIso();
job.events.push(
eventRecord({
fromStatus: beforeProcessing,
toStatus: VerificationStatus.PROCESSING,
actorType: 'system',
actorId: 'verification-worker',
})
);
try {
const outcome = await runMachineChecks(job);
if (!MACHINE_TERMINAL_STATUSES.has(outcome.status)) {
throw new Error(`Invalid machine outcome status: ${outcome.status}`);
}
const fromStatus = job.status;
job.status = outcome.status;
job.confidence = outcome.confidence;
job.reasons = outcome.reasons;
job.extracted = outcome.extracted;
job.provider = outcome.provider;
job.updatedAt = nowIso();
job.events.push(
eventRecord({
fromStatus,
toStatus: job.status,
actorType: 'system',
const result = startedJob.type === 'attire'
? await runAttireChecks(startedJob)
: await runThirdPartyChecks(startedJob, startedJob.type);
await withTransaction(async (client) => {
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
`,
[
verificationId,
result.status,
result.confidence,
JSON.stringify(result.reasons || []),
JSON.stringify(result.extracted || {}),
result.provider?.name || null,
result.provider?.reference || null,
]
);
await appendVerificationEvent(client, {
verificationJobId: verificationId,
fromStatus: VerificationStatus.PROCESSING,
toStatus: result.status,
actorType: 'worker',
actorId: 'verification-worker',
details: {
confidence: job.confidence,
reasons: job.reasons,
provider: job.provider,
confidence: result.confidence,
},
})
);
});
});
} catch (error) {
const fromStatus = job.status;
job.status = VerificationStatus.ERROR;
job.confidence = null;
job.reasons = [error?.message || 'Verification processing failed'];
job.extracted = {};
job.provider = {
name: 'verification-worker',
reference: null,
};
job.updatedAt = nowIso();
job.events.push(
eventRecord({
fromStatus,
await withTransaction(async (client) => {
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
`,
[
verificationId,
VerificationStatus.ERROR,
JSON.stringify([error?.message || 'Verification processing failed']),
`error:${error?.code || 'unknown'}`,
]
);
await appendVerificationEvent(client, {
verificationJobId: verificationId,
fromStatus: VerificationStatus.PROCESSING,
toStatus: VerificationStatus.ERROR,
actorType: 'system',
actorType: 'worker',
actorId: 'verification-worker',
details: {
error: error?.message || 'Verification processing failed',
},
})
);
}
}
function queueVerificationProcessing(id) {
setTimeout(() => {
processVerificationJob(id).catch(() => {});
}, 0);
}
export function createVerificationJob({ actorUid, payload }) {
const now = nowIso();
const id = `ver_${crypto.randomUUID()}`;
const job = {
id,
type: payload.type,
subjectType: payload.subjectType || null,
subjectId: payload.subjectId || null,
ownerUid: actorUid,
fileUri: payload.fileUri,
rules: payload.rules || {},
metadata: payload.metadata || {},
status: VerificationStatus.PENDING,
confidence: null,
reasons: [],
extracted: {},
provider: null,
review: null,
createdAt: now,
updatedAt: now,
events: [
eventRecord({
fromStatus: null,
toStatus: VerificationStatus.PENDING,
actorType: 'system',
actorId: actorUid,
}),
],
};
jobs.set(id, job);
queueVerificationProcessing(id);
return toPublicJob(job);
}
export function getVerificationJob(verificationId, actorUid) {
const job = requireJob(verificationId);
assertAccess(job, actorUid);
return toPublicJob(job);
}
export function reviewVerificationJob(verificationId, actorUid, review) {
const job = requireJob(verificationId);
assertAccess(job, actorUid);
if (HUMAN_TERMINAL_STATUSES.has(job.status)) {
throw new AppError('CONFLICT', 'Verification already finalized', 409, {
verificationId,
status: job.status,
});
});
}
}
const fromStatus = job.status;
job.status = review.decision;
job.review = {
decision: review.decision,
reviewedBy: actorUid,
reviewedAt: nowIso(),
note: review.note || '',
reasonCode: review.reasonCode || 'MANUAL_REVIEW',
};
job.updatedAt = nowIso();
job.events.push(
eventRecord({
fromStatus,
toStatus: job.status,
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);
assertAccess(job, actorUid);
return toPublicJob(job);
}
const job = await loadJob(verificationId);
assertAccess(job, actorUid);
return toPublicJob(job);
}
export async function reviewVerificationJob(verificationId, actorUid, review) {
if (useMemoryStore()) {
const job = loadMemoryJob(verificationId);
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];
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',
};
await client.query(
`
UPDATE verification_jobs
SET status = $2,
review = $3::jsonb,
updated_at = NOW()
WHERE id = $1
`,
[verificationId, review.decision, JSON.stringify(reviewPayload)]
);
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: job.review.reasonCode,
reasonCode: review.reasonCode || 'MANUAL_REVIEW',
},
})
);
});
return toPublicJob(job);
return {
...job,
status: review.decision,
review: reviewPayload,
updated_at: new Date().toISOString(),
};
});
void context;
return toPublicJob(updated);
}
export function retryVerificationJob(verificationId, actorUid) {
const job = requireJob(verificationId);
assertAccess(job, actorUid);
export async function retryVerificationJob(verificationId, actorUid) {
if (useMemoryStore()) {
const job = loadMemoryJob(verificationId);
assertAccess(job, actorUid);
if (job.status === VerificationStatus.PROCESSING) {
throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, {
verificationId,
});
}
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 fromStatus = job.status;
job.status = VerificationStatus.PENDING;
job.confidence = null;
job.reasons = [];
job.extracted = {};
job.provider = null;
job.review = null;
job.updatedAt = nowIso();
job.events.push(
eventRecord({
fromStatus,
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];
assertAccess(job, actorUid);
if (job.status === VerificationStatus.PROCESSING) {
throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, {
verificationId,
});
}
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
`,
[verificationId, VerificationStatus.PENDING]
);
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(job);
return toPublicJob(updated);
}
export function __resetVerificationJobsForTests() {
jobs.clear();
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.
}
}