fix(api): close v2 mobile contract gaps
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user