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);