// POST /dicom-analysis — Run Kimi K2.5 vision analysis on a DICOM image
ai.post('/dicom-analysis', async (c) => {
const user = c.get('user');
const clinicContext = c.get('clinicContext');
const clinicId = clinicContext?.currentClinicId || '';
const requestId = crypto.randomUUID();
let slotAcquired = false;
try {
const body = await c.req.json();
const parsed = dicomAnalysisSchema.parse(body);
const sliceCount = parsed.sliceCount ?? 1;
const databaseUrl = getDatabaseUrl();
const db = await getDatabase(databaseUrl);
await ensureDicomQuotaSchema(db);
// 1. Resolve billing period from dicom_imaging module activation date
const [mod] = await db
.select({ createdAt: clinicModules.createdAt, updatedAt: clinicModules.updatedAt,
dicomAiTermsAcceptedAt: clinicModules.dicomAiTermsAcceptedAt })
.from(clinicModules)
.where(and(eq(clinicModules.clinicId, clinicId), eq(clinicModules.moduleKey, 'dicom_imaging')))
.limit(1);
const activatedAt = mod?.createdAt ?? mod?.updatedAt ?? new Date();
const today = new Date();
const todayKey = today.toISOString().slice(0, 10);
const periodStart = computePeriodStart(activatedAt, today);
const nextReset = computeNextReset(activatedAt, today);
// 2. Load quota row (creates if missing, applies daily reset inline)
const quota = await loadOrCreateQuota(db, clinicId, periodStart, todayKey);
// 3-6. Check all limits
const limitErr = checkQuotaLimits(quota, nextReset, todayKey, sliceCount);
if (limitErr) {
return c.json({
error: 'quota_exceeded',
reason: limitErr.reason,
limit: limitErr.limit,
used: limitErr.used,
...(limitErr.resetsAt && { resetsAt: limitErr.resetsAt }),
}, limitErr.status);
}
// 7. Acquire concurrency slot
const slot = await acquireAnalysisSlot(c.env as any, clinicId, requestId);
if (!slot.granted) {
return c.json({
error: 'quota_exceeded',
reason: 'concurrent',
limit: 3,
used: slot.active,
}, 429);
}
slotAcquired = true;
const aiBinding = (c.env as any)?.AI;
if (!aiBinding) throw new AppError('Cloudflare Workers AI binding not configured', 503);
// 8. Run analysis
const result = await analyzeDicomImage({
patientFileId: parsed.patientFileId,
imageBase64: parsed.imageBase64,
modality: parsed.modality,
bodyPart: parsed.bodyPart,
clinicId,
userId: user.id,
ai: aiBinding,
});
// 9. Increment quota counters on success
await incrementQuota(db, clinicId, periodStart, todayKey, result.tokensInput, result.tokensOutput);
// Record activity in patient history (fire-and-forget, never throws)
try {
const [fileRow] = await db
.select({ patientId: patientFiles.patientId, fileName: patientFiles.fileName })
.from(patientFiles)
.where(eq(patientFiles.id, parsed.patientFileId))
.limit(1);
if (fileRow?.patientId) {
await recordActivity({
db,
clinicId,
userId: user.id,
entityType: 'patient',
entityId: fileRow.patientId,
action: 'dicom_ai_analysis',
message: `Ruby analysed ${fileRow.fileName} · ${result.data.modality ?? 'DICOM'} · ${result.data.urgencyLevel} urgency`,
patientId: fileRow.patientId,
});
}
} catch { /* activity logging never blocks the response */ }
return c.json({ success: true, data: result.data, traceId: result.traceId });
} catch (error) {
return handleError(error, c);
} finally {
// 10. Always release concurrency slot
if (slotAcquired) {
await releaseAnalysisSlot(c.env as any, clinicId, requestId).catch(() => {});
}
}
});
// GET /dicom-quota — Current period usage for the authenticated clinic
ai.get('/dicom-quota', async (c) => {
try {
const clinicContext = c.get('clinicContext');
const clinicId = clinicContext?.currentClinicId || '';
const databaseUrl = getDatabaseUrl();
const db = await getDatabase(databaseUrl);
await ensureDicomQuotaSchema(db);
const [mod] = await db
.select({ createdAt: clinicModules.createdAt, updatedAt: clinicModules.updatedAt,
dicomAiTermsAcceptedAt: clinicModules.dicomAiTermsAcceptedAt })
.from(clinicModules)
.where(and(eq(clinicModules.clinicId, clinicId), eq(clinicModules.moduleKey, 'dicom_imaging')))
.limit(1);
const activatedAt = mod?.createdAt ?? mod?.updatedAt ?? new Date();
const today = new Date();
const todayKey = today.toISOString().slice(0, 10);
const periodStart = computePeriodStart(activatedAt, today);
const nextReset = computeNextReset(activatedAt, today);
const quota = await loadOrCreateQuota(db, clinicId, periodStart, todayKey);
return c.json({
monthly_studies: quota.monthlyStudies,
monthly_limit: 500,
daily_studies: quota.dailyStudies,
daily_limit: 20,
tokens_input: quota.tokensInput,
tokens_output: quota.tokensOutput,
tokens_limit: 5_000_000,
period_start: periodStart,
next_reset: nextReset,
terms_accepted: !!mod?.dicomAiTermsAcceptedAt,
});
} catch (error) {
return handleError(error, c);
}
});
// POST /dicom-terms-accept — Record first-time terms acceptance for this clinic
ai.post('/dicom-terms-accept', async (c) => {
try {
const clinicContext = c.get('clinicContext');
const clinicId = clinicContext?.currentClinicId || '';
const databaseUrl = getDatabaseUrl();
const db = await getDatabase(databaseUrl);
await ensureDicomQuotaSchema(db);
await db
.update(clinicModules)
.set({ dicomAiTermsAcceptedAt: new Date() })
.where(and(
eq(clinicModules.clinicId, clinicId),
eq(clinicModules.moduleKey, 'dicom_imaging'),
sql`dicom_ai_terms_accepted_at IS NULL`,
));
return c.json({ success: true });
} catch (error) {
return handleError(error, c);
}
});
export default ai;