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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -267,6 +267,25 @@ test('POST /core/rapid-orders/parse rejects unknown fields', async () => {
|
||||
assert.equal(res.body.code, 'VALIDATION_ERROR');
|
||||
});
|
||||
|
||||
test('POST /core/rapid-orders/process accepts text-only flow', async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app)
|
||||
.post('/core/rapid-orders/process')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.send({
|
||||
text: 'Need 2 servers ASAP for 4 hours',
|
||||
locale: 'en-US',
|
||||
timezone: 'America/New_York',
|
||||
now: '2026-02-27T12:00:00.000Z',
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(typeof res.body.transcript, 'string');
|
||||
assert.equal(res.body.parsed.orderType, 'ONE_TIME');
|
||||
assert.equal(Array.isArray(res.body.parsed.positions), true);
|
||||
assert.equal(Array.isArray(res.body.catalog.roles), true);
|
||||
});
|
||||
|
||||
test('POST /core/rapid-orders/parse enforces per-user model rate limit', async () => {
|
||||
process.env.LLM_RATE_LIMIT_PER_MINUTE = '1';
|
||||
const app = createApp();
|
||||
|
||||
Reference in New Issue
Block a user