fix(api): close v2 mobile contract gaps

This commit is contained in:
zouantchaw
2026-03-17 22:37:45 +01:00
parent afcd896b47
commit 008dd7efb1
14 changed files with 1315 additions and 54 deletions

View File

@@ -10,6 +10,8 @@ import { rapidOrderParseSchema } from '../contracts/core/rapid-order-parse.js';
import { rapidOrderTranscribeSchema } from '../contracts/core/rapid-order-transcribe.js';
import { reviewVerificationSchema } from '../contracts/core/review-verification.js';
import { invokeVertexModel } from '../services/llm.js';
import { requireTenantContext } from '../services/actor-context.js';
import { isDatabaseConfigured, query as dbQuery } from '../services/db.js';
import { checkLlmRateLimit } from '../services/llm-rate-limit.js';
import { parseRapidOrderText, transcribeRapidOrderAudio } from '../services/rapid-order.js';
import {
@@ -26,6 +28,8 @@ import {
} from '../services/verification-jobs.js';
import {
deleteCertificate,
finalizeCertificateUpload,
finalizeStaffDocumentUpload,
uploadCertificate,
uploadProfilePhoto,
uploadStaffDocument,
@@ -70,6 +74,35 @@ const certificateUploadMetaSchema = z.object({
expiresAt: z.string().datetime().optional(),
});
const finalizedDocumentUploadSchema = z.object({
fileUri: z.string().max(4096).optional(),
photoUrl: z.string().max(4096).optional(),
verificationId: z.string().min(1).max(120),
}).strict();
const finalizedCertificateUploadSchema = certificateUploadMetaSchema.extend({
fileUri: z.string().max(4096).optional(),
photoUrl: z.string().max(4096).optional(),
verificationId: z.string().min(1).max(120),
}).strict();
const rapidOrderProcessSchema = z.object({
text: z.string().trim().min(1).max(4000).optional(),
audioFileUri: z.string().startsWith('gs://').max(2048).optional(),
locale: z.string().trim().min(2).max(35).optional().default('en-US'),
promptHints: z.array(z.string().trim().min(1).max(80)).max(20).optional().default([]),
timezone: z.string().trim().min(1).max(80).optional(),
now: z.string().datetime({ offset: true }).optional(),
}).strict().superRefine((value, ctx) => {
if (!value.text && !value.audioFileUri) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'text or audioFileUri is required',
path: ['text'],
});
}
});
function mockSignedUrl(fileUri, expiresInSeconds) {
const encoded = encodeURIComponent(fileUri);
const expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString();
@@ -114,6 +147,72 @@ function enforceLlmRateLimit(uid) {
}
}
function normalizeRoleToken(value) {
return `${value || ''}`
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
async function loadRapidOrderRoleCatalog(actorUid) {
if (!isDatabaseConfigured()) {
return [];
}
let context;
try {
context = await requireTenantContext(actorUid);
} catch {
return [];
}
const result = await dbQuery(
`
SELECT
rc.id AS "roleId",
rc.code AS "roleCode",
rc.name AS "roleName",
COALESCE(MAX(sr.bill_rate_cents), 0)::INTEGER AS "hourlyRateCents"
FROM roles_catalog rc
LEFT JOIN shift_roles sr ON sr.role_id = rc.id
LEFT JOIN shifts s ON s.id = sr.shift_id
WHERE rc.tenant_id = $1
AND rc.status = 'ACTIVE'
GROUP BY rc.id
ORDER BY rc.name ASC
`,
[context.tenant.tenantId]
);
return result.rows;
}
function enrichRapidOrderPositions(positions, roleCatalog) {
const catalog = roleCatalog.map((role) => ({
...role,
normalizedName: normalizeRoleToken(role.roleName),
normalizedCode: normalizeRoleToken(role.roleCode),
}));
return positions.map((position) => {
const normalizedRole = normalizeRoleToken(position.role);
const exact = catalog.find((role) => role.normalizedName === normalizedRole || role.normalizedCode === normalizedRole);
const fuzzy = exact || catalog.find((role) => (
role.normalizedName.includes(normalizedRole) || normalizedRole.includes(role.normalizedName)
));
return {
...position,
roleId: fuzzy?.roleId || null,
roleCode: fuzzy?.roleCode || null,
roleName: fuzzy?.roleName || position.role,
hourlyRateCents: fuzzy?.hourlyRateCents || 0,
matched: Boolean(fuzzy),
};
});
}
async function handleUploadFile(req, res, next) {
try {
const file = req.file;
@@ -280,9 +379,74 @@ async function handleRapidOrderParse(req, res, next) {
timezone: payload.timezone,
now: payload.now,
});
const roleCatalog = await loadRapidOrderRoleCatalog(req.actor.uid);
return res.status(200).json({
...result,
parsed: {
...result.parsed,
positions: enrichRapidOrderPositions(result.parsed.positions, roleCatalog),
},
catalog: {
roles: roleCatalog,
},
latencyMs: Date.now() - startedAt,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
}
async function handleRapidOrderProcess(req, res, next) {
try {
const payload = parseBody(rapidOrderProcessSchema, req.body || {});
enforceLlmRateLimit(req.actor.uid);
let transcript = payload.text || null;
if (!transcript && payload.audioFileUri) {
validateFileUriAccess({
fileUri: payload.audioFileUri,
actorUid: req.actor.uid,
});
if (requireRapidAudioFileExists() && !useMockUpload()) {
await ensureFileExistsForActor({
fileUri: payload.audioFileUri,
actorUid: req.actor.uid,
});
}
const transcribed = await transcribeRapidOrderAudio({
audioFileUri: payload.audioFileUri,
locale: payload.locale,
promptHints: payload.promptHints,
});
transcript = transcribed.transcript;
}
const startedAt = Date.now();
const parsed = await parseRapidOrderText({
text: transcript,
locale: payload.locale,
timezone: payload.timezone,
now: payload.now,
});
const roleCatalog = await loadRapidOrderRoleCatalog(req.actor.uid);
return res.status(200).json({
transcript,
parsed: {
...parsed.parsed,
positions: enrichRapidOrderPositions(parsed.parsed.positions, roleCatalog),
},
missingFields: parsed.missingFields,
warnings: parsed.warnings,
confidence: parsed.confidence,
catalog: {
roles: roleCatalog,
},
model: parsed.model,
latencyMs: Date.now() - startedAt,
requestId: req.requestId,
});
@@ -341,14 +505,25 @@ async function handleProfilePhotoUpload(req, res, next) {
async function handleDocumentUpload(req, res, next) {
try {
const file = req.file;
if (!file) {
throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400);
if (file) {
const result = await uploadStaffDocument({
actorUid: req.actor.uid,
documentId: req.params.documentId,
file,
routeType: 'document',
});
return res.status(200).json({
...result,
requestId: req.requestId,
});
}
const result = await uploadStaffDocument({
const payload = parseBody(finalizedDocumentUploadSchema, req.body || {});
const result = await finalizeStaffDocumentUpload({
actorUid: req.actor.uid,
documentId: req.params.documentId,
file,
routeType: 'document',
verificationId: payload.verificationId,
});
return res.status(200).json({
...result,
@@ -362,14 +537,25 @@ async function handleDocumentUpload(req, res, next) {
async function handleAttireUpload(req, res, next) {
try {
const file = req.file;
if (!file) {
throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400);
if (file) {
const result = await uploadStaffDocument({
actorUid: req.actor.uid,
documentId: req.params.documentId,
file,
routeType: 'attire',
});
return res.status(200).json({
...result,
requestId: req.requestId,
});
}
const result = await uploadStaffDocument({
const payload = parseBody(finalizedDocumentUploadSchema, req.body || {});
const result = await finalizeStaffDocumentUpload({
actorUid: req.actor.uid,
documentId: req.params.documentId,
file,
routeType: 'attire',
verificationId: payload.verificationId,
});
return res.status(200).json({
...result,
@@ -383,13 +569,22 @@ async function handleAttireUpload(req, res, next) {
async function handleCertificateUpload(req, res, next) {
try {
const file = req.file;
if (!file) {
throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400);
if (file) {
const payload = parseBody(certificateUploadMetaSchema, req.body || {});
const result = await uploadCertificate({
actorUid: req.actor.uid,
file,
payload,
});
return res.status(200).json({
...result,
requestId: req.requestId,
});
}
const payload = parseBody(certificateUploadMetaSchema, req.body || {});
const result = await uploadCertificate({
const payload = parseBody(finalizedCertificateUploadSchema, req.body || {});
const result = await finalizeCertificateUpload({
actorUid: req.actor.uid,
file,
payload,
});
return res.status(200).json({
@@ -464,9 +659,12 @@ export function createCoreRouter() {
router.post('/invoke-llm', requireAuth, requirePolicy('core.invoke-llm', 'model'), handleInvokeLlm);
router.post('/rapid-orders/transcribe', requireAuth, requirePolicy('core.rapid-order.transcribe', 'model'), handleRapidOrderTranscribe);
router.post('/rapid-orders/parse', requireAuth, requirePolicy('core.rapid-order.parse', 'model'), handleRapidOrderParse);
router.post('/rapid-orders/process', requireAuth, requirePolicy('core.rapid-order.process', 'model'), handleRapidOrderProcess);
router.post('/staff/profile/photo', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleProfilePhotoUpload);
router.post('/staff/documents/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleDocumentUpload);
router.put('/staff/documents/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), handleDocumentUpload);
router.post('/staff/attire/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleAttireUpload);
router.put('/staff/attire/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), handleAttireUpload);
router.post('/staff/certificates/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleCertificateUpload);
router.delete('/staff/certificates/:certificateType', requireAuth, requirePolicy('core.upload', 'file'), handleCertificateDelete);
router.post('/verifications', requireAuth, requirePolicy('core.verification.create', 'verification'), handleCreateVerification);

View File

@@ -2,7 +2,7 @@ import { AppError } from '../lib/errors.js';
import { requireStaffContext } from './actor-context.js';
import { generateReadSignedUrl, uploadToGcs } from './storage.js';
import { query, withTransaction } from './db.js';
import { createVerificationJob } from './verification-jobs.js';
import { createVerificationJob, getVerificationJob } from './verification-jobs.js';
function safeName(value) {
return `${value}`.replace(/[^a-zA-Z0-9._-]/g, '_');
@@ -40,6 +40,53 @@ async function createPreviewUrl(actorUid, fileUri) {
}
}
function normalizeDocumentStatusFromVerification(status) {
switch (`${status || ''}`.toUpperCase()) {
case 'AUTO_PASS':
case 'APPROVED':
return 'VERIFIED';
case 'AUTO_FAIL':
case 'REJECTED':
return 'REJECTED';
default:
return 'PENDING';
}
}
async function resolveVerificationBackedUpload({
actorUid,
verificationId,
subjectId,
allowedTypes,
}) {
if (!verificationId) {
throw new AppError('VALIDATION_ERROR', 'verificationId is required for finalized upload submission', 400);
}
const verification = await getVerificationJob(verificationId, actorUid);
if (subjectId && verification.subjectId && verification.subjectId !== subjectId) {
throw new AppError('VALIDATION_ERROR', 'verificationId does not belong to the requested subject', 400, {
verificationId,
subjectId,
verificationSubjectId: verification.subjectId,
});
}
if (allowedTypes && allowedTypes.length > 0 && !allowedTypes.includes(verification.type)) {
throw new AppError('VALIDATION_ERROR', 'verificationId type does not match the requested upload', 400, {
verificationId,
verificationType: verification.type,
allowedTypes,
});
}
return {
verification,
fileUri: verification.fileUri,
status: normalizeDocumentStatusFromVerification(verification.status),
};
}
export async function uploadProfilePhoto({ actorUid, file }) {
const context = await requireStaffContext(actorUid);
const uploaded = await uploadActorFile({
@@ -163,6 +210,76 @@ export async function uploadStaffDocument({ actorUid, documentId, file, routeTyp
};
}
export async function finalizeStaffDocumentUpload({
actorUid,
documentId,
routeType,
verificationId,
}) {
const context = await requireStaffContext(actorUid);
const document = await requireDocument(
context.tenant.tenantId,
documentId,
routeType === 'attire' ? ['ATTIRE'] : ['DOCUMENT', 'GOVERNMENT_ID', 'TAX_FORM']
);
const finalized = await resolveVerificationBackedUpload({
actorUid,
verificationId,
subjectId: documentId,
allowedTypes: routeType === 'attire'
? ['attire']
: ['government_id', 'document', 'tax_form'],
});
await withTransaction(async (client) => {
await client.query(
`
INSERT INTO staff_documents (
tenant_id,
staff_id,
document_id,
file_uri,
status,
verification_job_id,
metadata
)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
ON CONFLICT (staff_id, document_id) DO UPDATE
SET file_uri = EXCLUDED.file_uri,
status = EXCLUDED.status,
verification_job_id = EXCLUDED.verification_job_id,
metadata = COALESCE(staff_documents.metadata, '{}'::jsonb) || EXCLUDED.metadata,
updated_at = NOW()
`,
[
context.tenant.tenantId,
context.staff.staffId,
document.id,
finalized.fileUri,
finalized.status,
finalized.verification.verificationId,
JSON.stringify({
verificationStatus: finalized.verification.status,
routeType,
finalizedFromVerification: true,
}),
]
);
});
const preview = await createPreviewUrl(actorUid, finalized.fileUri);
return {
documentId: document.id,
documentType: document.document_type,
fileUri: finalized.fileUri,
signedUrl: preview.signedUrl,
expiresAt: preview.expiresAt,
verification: finalized.verification,
status: finalized.status,
};
}
export async function uploadCertificate({ actorUid, file, payload }) {
const context = await requireStaffContext(actorUid);
const uploaded = await uploadActorFile({
@@ -236,6 +353,106 @@ export async function uploadCertificate({ actorUid, file, payload }) {
};
}
export async function finalizeCertificateUpload({ actorUid, payload }) {
const context = await requireStaffContext(actorUid);
const finalized = await resolveVerificationBackedUpload({
actorUid,
verificationId: payload.verificationId,
subjectId: payload.certificateType,
allowedTypes: ['certification'],
});
const certificateResult = await withTransaction(async (client) => {
const existing = await client.query(
`
SELECT id
FROM certificates
WHERE tenant_id = $1
AND staff_id = $2
AND certificate_type = $3
ORDER BY created_at DESC
LIMIT 1
FOR UPDATE
`,
[context.tenant.tenantId, context.staff.staffId, payload.certificateType]
);
const metadata = JSON.stringify({
name: payload.name,
issuer: payload.issuer || null,
verificationStatus: finalized.verification.status,
finalizedFromVerification: true,
});
if (existing.rowCount > 0) {
return client.query(
`
UPDATE certificates
SET certificate_number = $2,
expires_at = $3,
status = $4,
file_uri = $5,
verification_job_id = $6,
metadata = COALESCE(metadata, '{}'::jsonb) || $7::jsonb,
updated_at = NOW()
WHERE id = $1
RETURNING id
`,
[
existing.rows[0].id,
payload.certificateNumber || null,
payload.expiresAt || null,
finalized.status,
finalized.fileUri,
finalized.verification.verificationId,
metadata,
]
);
}
return client.query(
`
INSERT INTO certificates (
tenant_id,
staff_id,
certificate_type,
certificate_number,
issued_at,
expires_at,
status,
file_uri,
verification_job_id,
metadata
)
VALUES ($1, $2, $3, $4, NOW(), $5, $6, $7, $8, $9::jsonb)
RETURNING id
`,
[
context.tenant.tenantId,
context.staff.staffId,
payload.certificateType,
payload.certificateNumber || null,
payload.expiresAt || null,
finalized.status,
finalized.fileUri,
finalized.verification.verificationId,
metadata,
]
);
});
const preview = await createPreviewUrl(actorUid, finalized.fileUri);
return {
certificateId: certificateResult.rows[0].id,
certificateType: payload.certificateType,
fileUri: finalized.fileUri,
signedUrl: preview.signedUrl,
expiresAt: preview.expiresAt,
verification: finalized.verification,
status: finalized.status,
};
}
export async function deleteCertificate({ actorUid, certificateType }) {
const context = await requireStaffContext(actorUid);
const result = await query(