Skip to main content

DICOM AI Quota, Safety & Observability — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Add per-clinic billing-cycle AI quotas, first-time beta terms modal, persistent AI disclaimer, patient history entries, and a superadmin usage monitor to the DICOM AI analysis feature. Architecture: D1 (dicom_quota table) stores durable monthly/daily/token counters keyed to the clinic’s billing period anchor; the existing clinic-hub Durable Object gets two new in-memory RPC methods for atomic concurrency slot management. UI adds a usage bar, terms modal, and disclaimer to DicomPage/DicomViewer, and a new superadmin panel reads quota rows. Tech Stack: Hono, Drizzle ORM, Cloudflare Workers, Durable Objects (RPC), Vitest, React, Lucide icons, Tailwind CSS, shadcn/ui

File Map

FileActionResponsibility
server/src/schema/dicom_quota.tsCreateDrizzle schema for quota table
server/src/schema/clinic_modules.tsModifyAdd createdAt, dicomAiTermsAcceptedAt columns
server/src/schema/index.tsModifyExport new schema
server/src/lib/schema-ensure.tsModifyensureDicomQuotaSchema runtime migration
server/src/lib/dicom-quota.tsCreatePeriod calculation + quota load/check/increment helpers
server/src/lib/dicom-quota.test.tsCreateVitest unit tests for pure quota helpers
server/src/durable-objects/clinic-hub.tsModifyacquireAnalysisSlot / releaseAnalysisSlot RPC methods
server/src/lib/event-bus.tsModifyacquireAnalysisSlot / releaseAnalysisSlot helper functions
server/src/lib/ai/agents/dicom-analysis.tsModifymax_completion_tokens: 1000, temperature: 0.2, return tokenUsage
server/src/routes/ai.tsModifyQuota enforcement on POST; add GET quota + POST terms-accept routes
server/src/routes/stats.tsModifyAdd superadmin DICOM usage endpoint
ui/src/components/files/DicomPage.tsxModifyUsage counter bar in header
ui/src/components/files/DicomViewer.tsxModifyTerms modal, persistent disclaimer, quota error banners
ui/src/components/superadmin/DicomUsageMonitor.tsxCreateSuperadmin per-clinic quota table
ui/src/components/dashboards/SuperAdminDashboard.tsxModifyRegister dicom-usage view

Task 1: DB Schema — dicom_quota + clinic_modules additions

Files:
  • Create: server/src/schema/dicom_quota.ts
  • Modify: server/src/schema/clinic_modules.ts
  • Modify: server/src/schema/index.ts
  • Step 1: Create server/src/schema/dicom_quota.ts
import { text, integer, timestamp, primaryKey } from 'drizzle-orm/pg-core';
import { appSchema } from './base';

export const dicomQuota = appSchema.table('dicom_quota', {
  clinicId: text('clinic_id').notNull(),
  periodStart: text('period_start').notNull(), // "YYYY-MM-DD"
  monthlyStudies: integer('monthly_studies').default(0).notNull(),
  dailyStudies: integer('daily_studies').default(0).notNull(),
  dayKey: text('day_key').notNull(),           // "YYYY-MM-DD" of last daily reset
  tokensInput: integer('tokens_input').default(0).notNull(),
  tokensOutput: integer('tokens_output').default(0).notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (t) => ({
  pk: primaryKey({ columns: [t.clinicId, t.periodStart] }),
}));

export type DicomQuota = typeof dicomQuota.$inferSelect;
export type NewDicomQuota = typeof dicomQuota.$inferInsert;
  • Step 2: Add createdAt and dicomAiTermsAcceptedAt to server/src/schema/clinic_modules.ts
Replace the existing file content with:
import { pgTable, text, boolean, timestamp, unique } from 'drizzle-orm/pg-core';
import { appSchema } from './base';
import { clinics } from './clinics';

export const clinicModules = appSchema.table('clinic_modules', {
    id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
    clinicId: text('clinic_id').references(() => clinics.id).notNull(),
    moduleKey: text('module_key').notNull(),
    isEnabled: boolean('is_enabled').default(false).notNull(),
    config: text('config'),
    createdAt: timestamp('created_at').defaultNow().notNull(),
    updatedAt: timestamp('updated_at').defaultNow().notNull(),
    updatedBy: text('updated_by'),
    dicomAiTermsAcceptedAt: timestamp('dicom_ai_terms_accepted_at'),
}, (t) => ({
    uniqueModulePerClinic: unique().on(t.clinicId, t.moduleKey),
}));

export type ClinicModule = typeof clinicModules.$inferSelect;
export type NewClinicModule = typeof clinicModules.$inferInsert;
  • Step 3: Export dicom_quota from server/src/schema/index.ts
Add after the clinic_modules export line:
export * from './dicom_quota';
  • Step 4: Commit
git add server/src/schema/dicom_quota.ts server/src/schema/clinic_modules.ts server/src/schema/index.ts
git commit -m "feat(schema): add dicom_quota table and clinic_modules quota columns"

Task 2: Runtime Migration

Files:
  • Modify: server/src/lib/schema-ensure.ts
  • Step 1: Add dicomQuotaEnsured flag at the top of schema-ensure.ts
Add to the existing block of let ... = false; declarations at the top of the file:
let dicomQuotaEnsured = false;
  • Step 2: Add ensureDicomQuotaSchema function at the end of schema-ensure.ts
export async function ensureDicomQuotaSchema(db: any): Promise<void> {
  if (dicomQuotaEnsured) return;
  const stmts = [
    `CREATE TABLE IF NOT EXISTS app.dicom_quota (
      clinic_id text NOT NULL,
      period_start text NOT NULL,
      monthly_studies integer DEFAULT 0 NOT NULL,
      daily_studies integer DEFAULT 0 NOT NULL,
      day_key text NOT NULL,
      tokens_input integer DEFAULT 0 NOT NULL,
      tokens_output integer DEFAULT 0 NOT NULL,
      updated_at timestamp DEFAULT now() NOT NULL,
      PRIMARY KEY (clinic_id, period_start)
    )`,
    `CREATE INDEX IF NOT EXISTS dicom_quota_clinic_idx ON app.dicom_quota (clinic_id)`,
    `ALTER TABLE app.clinic_modules ADD COLUMN IF NOT EXISTS created_at timestamp DEFAULT now() NOT NULL`,
    `ALTER TABLE app.clinic_modules ADD COLUMN IF NOT EXISTS dicom_ai_terms_accepted_at timestamp`,
  ];
  for (const stmt of stmts) {
    try { await db.execute(sql.raw(stmt)); } catch { /* already exists — safe */ }
  }
  dicomQuotaEnsured = true;
}
  • Step 3: Commit
git add server/src/lib/schema-ensure.ts
git commit -m "feat(schema-ensure): add dicom_quota table and clinic_modules quota columns"

Task 3: Quota Helpers + Tests

Files:
  • Create: server/src/lib/dicom-quota.ts
  • Create: server/src/lib/dicom-quota.test.ts
  • Step 1: Write the failing tests first — server/src/lib/dicom-quota.test.ts
import { describe, it, expect } from 'vitest';
import { computePeriodStart, computeNextReset, checkQuotaLimits } from './dicom-quota';

describe('computePeriodStart', () => {
  it('returns same month anchor when today is after anchor day', () => {
    // anchor: 15th, today: April 27 → period starts April 15
    const result = computePeriodStart(new Date('2026-04-15T10:00:00Z'), new Date('2026-04-27T00:00:00Z'));
    expect(result).toBe('2026-04-15');
  });

  it('returns previous month anchor when today is before anchor day', () => {
    // anchor: 27th, today: April 10 → period starts March 27
    const result = computePeriodStart(new Date('2026-03-27T10:00:00Z'), new Date('2026-04-10T00:00:00Z'));
    expect(result).toBe('2026-03-27');
  });

  it('clamps anchor day 31 to last day of short month', () => {
    // anchor: 31st, today: March 5 → period starts Feb 28
    const result = computePeriodStart(new Date('2026-01-31T10:00:00Z'), new Date('2026-03-05T00:00:00Z'));
    expect(result).toBe('2026-02-28');
  });

  it('returns same day when today IS the anchor day', () => {
    const result = computePeriodStart(new Date('2026-04-15T10:00:00Z'), new Date('2026-04-15T00:00:00Z'));
    expect(result).toBe('2026-04-15');
  });
});

describe('computeNextReset', () => {
  it('returns next month same anchor day', () => {
    const result = computeNextReset(new Date('2026-04-15T10:00:00Z'), new Date('2026-04-27T00:00:00Z'));
    expect(result).toBe('2026-05-15');
  });

  it('clamps anchor day 31 in target month', () => {
    // anchor: 31, current period starts Jan 31 → next reset is Feb 28
    const result = computeNextReset(new Date('2026-01-31T10:00:00Z'), new Date('2026-01-31T00:00:00Z'));
    expect(result).toBe('2026-02-28');
  });
});

describe('checkQuotaLimits', () => {
  const baseQuota = {
    monthlyStudies: 0, dailyStudies: 0,
    tokensInput: 0, tokensOutput: 0,
    periodStart: '2026-04-15', dayKey: '2026-04-27',
  };
  const nextReset = '2026-05-15';

  it('returns null when all limits are under', () => {
    expect(checkQuotaLimits(baseQuota, nextReset, '2026-04-27', 1)).toBeNull();
  });

  it('blocks on monthly_studies at 500', () => {
    const r = checkQuotaLimits({ ...baseQuota, monthlyStudies: 500 }, nextReset, '2026-04-27', 1);
    expect(r?.reason).toBe('monthly_studies');
    expect(r?.limit).toBe(500);
  });

  it('blocks on monthly tokens at 5M', () => {
    const r = checkQuotaLimits({ ...baseQuota, tokensInput: 4_000_000, tokensOutput: 1_000_001 }, nextReset, '2026-04-27', 1);
    expect(r?.reason).toBe('monthly_tokens');
  });

  it('blocks on daily_studies at 20', () => {
    const r = checkQuotaLimits({ ...baseQuota, dailyStudies: 20 }, nextReset, '2026-04-27', 1);
    expect(r?.reason).toBe('daily_studies');
  });

  it('returns null for daily_studies on a different day (already reset)', () => {
    // dayKey is yesterday — the load function resets before we check, but checkQuotaLimits
    // operates on the already-reset row so daily_studies would be 0
    expect(checkQuotaLimits({ ...baseQuota, dailyStudies: 0 }, nextReset, '2026-04-27', 1)).toBeNull();
  });

  it('blocks on slice_limit at 31', () => {
    const r = checkQuotaLimits(baseQuota, nextReset, '2026-04-27', 31);
    expect(r?.reason).toBe('slice_limit');
    expect(r?.status).toBe(400);
  });
});
  • Step 2: Run tests to verify they fail
cd /Users/ssh/Documents/Beta-App/odontoX/server && npx vitest run src/lib/dicom-quota.test.ts
Expected: FAIL — “Cannot find module ’./dicom-quota’”
  • Step 3: Create server/src/lib/dicom-quota.ts
import { sql } from 'drizzle-orm';

export interface QuotaRow {
  monthlyStudies: number;
  dailyStudies: number;
  tokensInput: number;
  tokensOutput: number;
  periodStart: string;
  dayKey: string;
}

export interface QuotaError {
  reason: 'monthly_studies' | 'monthly_tokens' | 'daily_studies' | 'slice_limit';
  limit: number;
  used: number;
  resetsAt?: string; // ISO UTC — omitted for slice_limit
  status: 400 | 429;
}

const MONTHLY_STUDIES_LIMIT = 500;
const MONTHLY_TOKENS_LIMIT = 5_000_000;
const DAILY_STUDIES_LIMIT = 20;
const SLICE_LIMIT = 30;

function clampDay(year: number, month: number, day: number): Date {
  const d = new Date(Date.UTC(year, month - 1, day));
  if (d.getUTCMonth() !== month - 1) {
    // Overflowed — back up to last day of intended month
    return new Date(Date.UTC(year, month - 1, 0));
  }
  return d;
}

function toYMD(d: Date): string {
  return d.toISOString().slice(0, 10);
}

export function computePeriodStart(activatedAt: Date, today: Date = new Date()): string {
  const anchorDay = activatedAt.getUTCDate();
  const yr = today.getUTCFullYear();
  const mo = today.getUTCMonth() + 1; // 1-based
  const todayDay = today.getUTCDate();

  const candidateThisMonth = clampDay(yr, mo, anchorDay);
  if (candidateThisMonth.getUTCDate() <= todayDay) {
    return toYMD(candidateThisMonth);
  }
  // Anchor hasn't happened yet this month — use previous month
  const prevMo = mo === 1 ? 12 : mo - 1;
  const prevYr = mo === 1 ? yr - 1 : yr;
  return toYMD(clampDay(prevYr, prevMo, anchorDay));
}

export function computeNextReset(activatedAt: Date, today: Date = new Date()): string {
  const periodStart = computePeriodStart(activatedAt, today);
  const [yr, mo] = periodStart.split('-').map(Number);
  const anchorDay = activatedAt.getUTCDate();
  const nextMo = mo === 12 ? 1 : mo + 1;
  const nextYr = mo === 12 ? yr + 1 : yr;
  return toYMD(clampDay(nextYr, nextMo, anchorDay));
}

export function checkQuotaLimits(
  quota: QuotaRow,
  nextReset: string,
  _todayKey: string,
  sliceCount: number
): QuotaError | null {
  if (sliceCount > SLICE_LIMIT) {
    return { reason: 'slice_limit', limit: SLICE_LIMIT, used: sliceCount, status: 400 };
  }
  if (quota.monthlyStudies >= MONTHLY_STUDIES_LIMIT) {
    return { reason: 'monthly_studies', limit: MONTHLY_STUDIES_LIMIT, used: quota.monthlyStudies,
      resetsAt: `${nextReset}T00:00:00Z`, status: 429 };
  }
  if (quota.tokensInput + quota.tokensOutput >= MONTHLY_TOKENS_LIMIT) {
    return { reason: 'monthly_tokens', limit: MONTHLY_TOKENS_LIMIT,
      used: quota.tokensInput + quota.tokensOutput,
      resetsAt: `${nextReset}T00:00:00Z`, status: 429 };
  }
  if (quota.dailyStudies >= DAILY_STUDIES_LIMIT) {
    const tomorrow = new Date();
    tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
    return { reason: 'daily_studies', limit: DAILY_STUDIES_LIMIT, used: quota.dailyStudies,
      resetsAt: `${toYMD(tomorrow)}T00:00:00Z`, status: 429 };
  }
  return null;
}

export async function loadOrCreateQuota(
  db: any,
  clinicId: string,
  periodStart: string,
  todayKey: string
): Promise<QuotaRow> {
  // Upsert with daily reset inline
  await db.execute(sql.raw(`
    INSERT INTO app.dicom_quota (clinic_id, period_start, monthly_studies, daily_studies, day_key, tokens_input, tokens_output, updated_at)
    VALUES ('${clinicId}', '${periodStart}', 0, 0, '${todayKey}', 0, 0, now())
    ON CONFLICT (clinic_id, period_start) DO NOTHING
  `));

  // Apply daily reset if day_key has changed
  await db.execute(sql.raw(`
    UPDATE app.dicom_quota
    SET daily_studies = 0, day_key = '${todayKey}', updated_at = now()
    WHERE clinic_id = '${clinicId}' AND period_start = '${periodStart}' AND day_key != '${todayKey}'
  `));

  const rows = await db.execute(sql.raw(`
    SELECT monthly_studies, daily_studies, tokens_input, tokens_output, period_start, day_key
    FROM app.dicom_quota
    WHERE clinic_id = '${clinicId}' AND period_start = '${periodStart}'
    LIMIT 1
  `));

  const r = rows.rows?.[0] ?? rows[0];
  return {
    monthlyStudies: Number(r.monthly_studies),
    dailyStudies: Number(r.daily_studies),
    tokensInput: Number(r.tokens_input),
    tokensOutput: Number(r.tokens_output),
    periodStart: r.period_start,
    dayKey: r.day_key,
  };
}

export async function incrementQuota(
  db: any,
  clinicId: string,
  periodStart: string,
  todayKey: string,
  tokensInput: number,
  tokensOutput: number
): Promise<void> {
  await db.execute(sql.raw(`
    UPDATE app.dicom_quota
    SET
      monthly_studies = monthly_studies + 1,
      daily_studies = daily_studies + 1,
      tokens_input = tokens_input + ${tokensInput},
      tokens_output = tokens_output + ${tokensOutput},
      day_key = '${todayKey}',
      updated_at = now()
    WHERE clinic_id = '${clinicId}' AND period_start = '${periodStart}'
  `));
}
  • Step 4: Run tests to verify they pass
cd /Users/ssh/Documents/Beta-App/odontoX/server && npx vitest run src/lib/dicom-quota.test.ts
Expected: All 9 tests PASS
  • Step 5: Commit
git add server/src/lib/dicom-quota.ts server/src/lib/dicom-quota.test.ts
git commit -m "feat(server): add dicom quota helpers with period calculation and limit checks"

Task 4: Durable Object Concurrency Slots

Files:
  • Modify: server/src/durable-objects/clinic-hub.ts
  • Modify: server/src/lib/event-bus.ts
  • Step 1: Add slot methods to server/src/durable-objects/clinic-hub.ts
Add after the private events: StoredEvent[] = []; line:
  private activeAnalyses = 0;
  private slotTimestamps = new Map<string, number>();
Add after the webSocketError method (before the closing }):
  acquireAnalysisSlot(requestId: string): { granted: boolean; active: number } {
    this.sweepStaleSlots();
    if (this.activeAnalyses >= 3) return { granted: false, active: this.activeAnalyses };
    this.activeAnalyses++;
    this.slotTimestamps.set(requestId, Date.now());
    return { granted: true, active: this.activeAnalyses };
  }

  releaseAnalysisSlot(requestId: string): void {
    if (this.slotTimestamps.has(requestId)) {
      this.slotTimestamps.delete(requestId);
      this.activeAnalyses = Math.max(0, this.activeAnalyses - 1);
    }
  }

  private sweepStaleSlots(): void {
    const cutoff = Date.now() - 5 * 60 * 1000;
    for (const [id, ts] of this.slotTimestamps) {
      if (ts < cutoff) {
        this.slotTimestamps.delete(id);
        this.activeAnalyses = Math.max(0, this.activeAnalyses - 1);
      }
    }
  }
  • Step 2: Add slot helper functions to server/src/lib/event-bus.ts
Add after the publishEvent function:
export async function acquireAnalysisSlot(
  env: { CLINIC_HUB?: DurableObjectNamespace },
  clinicId: string,
  requestId: string
): Promise<{ granted: boolean; active: number }> {
  if (!env.CLINIC_HUB) return { granted: true, active: 0 };
  let hub = stubCache.get(clinicId);
  if (!hub) {
    const id = env.CLINIC_HUB.idFromName(clinicId);
    hub = env.CLINIC_HUB.get(id) as DurableObjectStub<ClinicHub>;
    stubCache.set(clinicId, hub);
  }
  return (hub as any).acquireAnalysisSlot(requestId);
}

export async function releaseAnalysisSlot(
  env: { CLINIC_HUB?: DurableObjectNamespace },
  clinicId: string,
  requestId: string
): Promise<void> {
  if (!env.CLINIC_HUB) return;
  let hub = stubCache.get(clinicId);
  if (!hub) {
    const id = env.CLINIC_HUB.idFromName(clinicId);
    hub = env.CLINIC_HUB.get(id) as DurableObjectStub<ClinicHub>;
    stubCache.set(clinicId, hub);
  }
  await (hub as any).releaseAnalysisSlot(requestId);
}
  • Step 3: Commit
git add server/src/durable-objects/clinic-hub.ts server/src/lib/event-bus.ts
git commit -m "feat(do): add acquireAnalysisSlot/releaseAnalysisSlot to ClinicHub"

Task 5: Update dicom-analysis Agent Parameters

Files:
  • Modify: server/src/lib/ai/agents/dicom-analysis.ts
  • Step 1: Update AI call parameters and return token usage
In analyzeDicomImage, locate the ai.run() call (around line 145–158) and change:
      max_tokens: 8192,
      temperature: 0.1,
to:
      max_completion_tokens: 1000,
      temperature: 0.2,
  • Step 2: Return token usage from analyzeDicomImage
Change the return type signature at line 98:
export async function analyzeDicomImage(
  input: AnalyzeDicomInput
): Promise<{ data: DicomAIAnalysis; traceId: string; tokensInput: number; tokensOutput: number }>
At the end of the function where it currently returns { data: analysisResult, traceId: trace.id }, change to:
  const usage = aiResponse?.usage ?? aiResponse?.choices?.[0]?.usage;
  return {
    data: analysisResult,
    traceId: trace.id,
    tokensInput: usage?.prompt_tokens ?? 0,
    tokensOutput: usage?.completion_tokens ?? 0,
  };
Note: the usage variable is already computed earlier in the function (around line 214) — remove that duplicate and use this one at the return point. Make sure to read the full function before editing to avoid duplicating the usage extraction.
  • Step 3: Commit
git add server/src/lib/ai/agents/dicom-analysis.ts
git commit -m "fix(ai): reduce dicom max_completion_tokens to 1000, return token usage"

Task 6: AI Route — Quota Enforcement + New Endpoints

Files:
  • Modify: server/src/routes/ai.ts
  • Step 1: Add imports at the top of ai.ts
After the existing imports, add:
import { clinicModules } from '../schema';
import { recordActivity } from '../lib/activity';
import {
  computePeriodStart, computeNextReset,
  loadOrCreateQuota, checkQuotaLimits, incrementQuota,
} from '../lib/dicom-quota';
import { acquireAnalysisSlot, releaseAnalysisSlot } from '../lib/event-bus';
import { ensureDicomQuotaSchema } from '../lib/schema-ensure';
  • Step 2: Update dicomAnalysisSchema to include sliceCount
Find the existing dicomAnalysisSchema (around line 71) and replace it with:
const dicomAnalysisSchema = z.object({
  patientFileId: z.string().uuid('Invalid file ID'),
  imageBase64: z.string().min(1, 'imageBase64 is required'),
  modality: z.string().optional(),
  bodyPart: z.string().optional(),
  sliceCount: z.number().int().min(1).max(100).optional().default(1),
});
  • Step 3: Replace the POST /dicom-analysis handler
Replace everything from ai.post('/dicom-analysis', ...) through export default ai; with:
// 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;
  • Step 4: Commit
git add server/src/routes/ai.ts
git commit -m "feat(api): DICOM quota enforcement, GET quota, POST terms-accept"

Task 7: Superadmin DICOM Usage Endpoint

Files:
  • Modify: server/src/routes/stats.ts
  • Step 1: Add the superadmin dicom-usage route to stats.ts
Add after the existing stats.get('/superadmin', ...) handler (before export default stats):
// GET /superadmin/dicom-usage — Per-clinic DICOM quota monitor (superadmin only)
stats.get('/superadmin/dicom-usage', async (c) => {
  try {
    const user = c.get('user');
    if (user.role !== 'superadmin') return c.json({ error: 'Unauthorized' }, 403);

    const databaseUrl = getDatabaseUrl();
    const db = await getDatabase(databaseUrl);

    const rows = await db.execute(sql`
      SELECT
        q.clinic_id,
        cl.name AS clinic_name,
        q.period_start,
        q.monthly_studies,
        q.daily_studies,
        q.tokens_input,
        q.tokens_output,
        q.updated_at,
        cm.dicom_ai_terms_accepted_at AS terms_accepted_at,
        cm.created_at AS module_activated_at
      FROM app.dicom_quota q
      JOIN app.clinics cl ON cl.id = q.clinic_id
      LEFT JOIN app.clinic_modules cm
        ON cm.clinic_id = q.clinic_id AND cm.module_key = 'dicom_imaging'
      ORDER BY q.monthly_studies DESC, cl.name ASC
    `);

    const data = (rows.rows ?? rows).map((r: any) => ({
      clinic_id: r.clinic_id,
      clinic_name: r.clinic_name,
      period_start: r.period_start,
      monthly_studies: Number(r.monthly_studies),
      daily_studies: Number(r.daily_studies),
      tokens_input: Number(r.tokens_input),
      tokens_output: Number(r.tokens_output),
      updated_at: r.updated_at,
      terms_accepted_at: r.terms_accepted_at ?? null,
      module_activated_at: r.module_activated_at ?? null,
    }));

    return c.json({ data });
  } catch (error) {
    return handleError(error, c);
  }
});
Also add the missing import at the top of stats.ts (check if sql is already imported from drizzle-orm — if not, add it alongside existing imports).
  • Step 2: Commit
git add server/src/routes/stats.ts
git commit -m "feat(api): superadmin DICOM usage endpoint"

Task 8: UI — DicomPage Usage Counter + DicomViewer Terms/Disclaimer/Errors

Files:
  • Modify: ui/src/components/files/DicomPage.tsx
  • Modify: ui/src/components/files/DicomViewer.tsx

DicomPage usage counter

  • Step 1: Add quota fetch and usage bar to DicomPage.tsx
At the top of the component function, add state and effect (after existing state):
const [quota, setQuota] = useState<{
  monthly_studies: number; monthly_limit: number;
  daily_studies: number; daily_limit: number;
  tokens_input: number; tokens_output: number; tokens_limit: number;
  next_reset: string; terms_accepted: boolean;
} | null>(null);

useEffect(() => {
  fetchWithAuth('/api/v1/protected/ai/dicom-quota')
    .then(r => r.json())
    .then(setQuota)
    .catch(() => {});
}, []);
Add a getUsageColor helper inside the component:
function getUsageColor(used: number, limit: number): string {
  const pct = used / limit;
  if (pct >= 0.95) return 'bg-red-500';
  if (pct >= 0.80) return 'bg-amber-500';
  return 'bg-emerald-500';
}
In the header JSX (find the header/title area of DicomPage), add this after the existing header content:
{quota && (
  <div className="flex items-center gap-3 text-xs text-muted-foreground">
    <div className="flex flex-col gap-0.5 min-w-[180px]">
      <div className="flex items-center justify-between">
        <span>AI Analyses</span>
        <span className="font-medium text-foreground">{quota.monthly_studies} / {quota.monthly_limit}</span>
      </div>
      <div className="h-1.5 rounded-full bg-muted overflow-hidden">
        <div
          className={`h-full rounded-full transition-all ${getUsageColor(quota.monthly_studies, quota.monthly_limit)}`}
          style={{ width: `${Math.min(100, (quota.monthly_studies / quota.monthly_limit) * 100)}%` }}
        />
      </div>
      <div className="flex items-center justify-between text-[10px]">
        <span>{quota.daily_studies} / {quota.daily_limit} today</span>
        <span>Resets {new Date(quota.next_reset + 'T00:00:00Z').toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}</span>
      </div>
    </div>
  </div>
)}

DicomViewer — terms modal, disclaimer, error banners

  • Step 2: Add quota state and terms modal logic to DicomViewer.tsx
Add imports at the top (add to existing imports):
import { AlertTriangle, Brain } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
Add state variables inside the component:
const [quotaError, setQuotaError] = useState<{
  reason: string; limit: number; used: number; resetsAt?: string;
} | null>(null);
const [showTermsModal, setShowTermsModal] = useState(false);
const [termsAccepted, setTermsAccepted] = useState<boolean | null>(null);
const [pendingAnalysis, setPendingAnalysis] = useState(false);
Load termsAccepted state once on mount:
useEffect(() => {
  fetchWithAuth('/api/v1/protected/ai/dicom-quota')
    .then(r => r.json())
    .then((q: any) => setTermsAccepted(q.terms_accepted ?? false))
    .catch(() => setTermsAccepted(true)); // fail open
}, []);
  • Step 3: Update the “Analyse with AI” click handler in DicomViewer.tsx
Find the existing click handler for the “Analyse with AI” button. Wrap the existing analysis logic with a terms check:
const handleAnalyseClick = async () => {
  setQuotaError(null);
  if (termsAccepted === false) {
    setShowTermsModal(true);
    return;
  }
  await runAnalysis();
};

const handleTermsAccept = async () => {
  try {
    await fetchWithAuth('/api/v1/protected/ai/dicom-terms-accept', { method: 'POST' });
    setTermsAccepted(true);
  } catch { /* fail open */ }
  setShowTermsModal(false);
  await runAnalysis();
};
Rename the existing analysis trigger function to runAnalysis and wire up the handleAnalyseClick to the button’s onClick. In the analysis fetch call, catch 429 and 400 errors:
if (!response.ok) {
  if (response.status === 429 || response.status === 400) {
    const err = await response.json();
    setQuotaError(err);
    return;
  }
  throw new Error('Analysis failed');
}
  • Step 4: Add the terms modal JSX to DicomViewer.tsx
Add before the closing return JSX tag:
<Dialog open={showTermsModal} onOpenChange={setShowTermsModal}>
  <DialogContent className="max-w-md">
    <DialogHeader>
      <DialogTitle className="flex items-center gap-2">
        <Brain className="h-5 w-5 text-violet-500" />
        Ruby AI Analysis — Beta
      </DialogTitle>
      <DialogDescription className="sr-only">Terms for using Ruby AI DICOM analysis</DialogDescription>
    </DialogHeader>
    <div className="space-y-3 text-sm text-muted-foreground">
      <p>You're about to use AI-powered radiograph analysis. A few things to know:</p>
      <ul className="space-y-1.5 list-none">
        <li>• This feature is in <strong className="text-foreground">beta</strong> — results are thorough but always need clinical review</li>
        <li>• Ruby highlights findings, but a licensed dentist must make all clinical decisions</li>
        <li>• Each analysis uses your clinic's monthly quota (500 studies/period)</li>
        <li>• Completed reports are saved to the patient file for reference</li>
      </ul>
    </div>
    <div className="flex gap-2 pt-2">
      <Button onClick={handleTermsAccept} className="flex-1">
        I understand — start analysis
      </Button>
      <Button variant="outline" onClick={() => setShowTermsModal(false)}>Cancel</Button>
    </div>
  </DialogContent>
</Dialog>
  • Step 5: Add persistent AI disclaimer and quota error banner to results panel
In the AI results panel (find where analysisResult or aiAnalysis is rendered), add at the very top of that panel:
{/* Always-visible disclaimer — non-dismissable */}
{analysisResult && (
  <div className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-400">
    <AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
    <span>AI-generated analysis — A licensed dentist must review all findings and make all clinical decisions. This is not a clinical diagnosis.</span>
  </div>
)}
Add quota error banner (rendered when quotaError is set, above the “Analyse with AI” button area):
{quotaError && (
  <div className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-400">
    <AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
    <span>
      {quotaError.reason === 'monthly_studies' && `Your clinic has used all ${quotaError.limit} AI analyses for this period. Resets on ${quotaError.resetsAt ? new Date(quotaError.resetsAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'long' }) : 'next billing date'}.`}
      {quotaError.reason === 'monthly_tokens' && `Your clinic's monthly AI token budget is exhausted. Resets on ${quotaError.resetsAt ? new Date(quotaError.resetsAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'long' }) : 'next billing date'}.`}
      {quotaError.reason === 'daily_studies' && 'Daily limit reached — 20 analyses per day keeps costs fair for everyone. Back to full speed tomorrow.'}
      {quotaError.reason === 'concurrent' && 'An analysis is already running. Please wait a few seconds and try again.'}
      {quotaError.reason === 'slice_limit' && `This study has too many frames to analyse at once. Please select a smaller slice range (max ${quotaError.limit}).`}
    </span>
  </div>
)}
  • Step 6: Commit
git add ui/src/components/files/DicomPage.tsx ui/src/components/files/DicomViewer.tsx
git commit -m "feat(ui): DICOM usage counter, terms modal, AI disclaimer, quota error banners"

Task 9: Superadmin DicomUsageMonitor

Files:
  • Create: ui/src/components/superadmin/DicomUsageMonitor.tsx
  • Modify: ui/src/components/dashboards/SuperAdminDashboard.tsx
  • Step 1: Create ui/src/components/superadmin/DicomUsageMonitor.tsx
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { fetchWithAuth } from '@/lib/serverComm';
import { Brain } from 'lucide-react';

interface DicomUsageRow {
  clinic_id: string;
  clinic_name: string;
  period_start: string;
  monthly_studies: number;
  daily_studies: number;
  tokens_input: number;
  tokens_output: number;
  updated_at: string;
  terms_accepted_at: string | null;
  module_activated_at: string | null;
}

function UsageBar({ used, limit }: { used: number; limit: number }) {
  const pct = Math.min(100, (used / limit) * 100);
  const color = pct >= 95 ? 'bg-red-500' : pct >= 80 ? 'bg-amber-500' : 'bg-emerald-500';
  return (
    <div className="flex items-center gap-2 min-w-[120px]">
      <div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
        <div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
      </div>
      <span className="text-xs text-muted-foreground whitespace-nowrap">{used} / {limit}</span>
    </div>
  );
}

export default function DicomUsageMonitor() {
  const [rows, setRows] = useState<DicomUsageRow[]>([]);
  const [loading, setLoading] = useState(true);
  const [sortKey, setSortKey] = useState<keyof DicomUsageRow>('monthly_studies');
  const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');

  useEffect(() => {
    fetchWithAuth('/api/v1/protected/stats/superadmin/dicom-usage')
      .then(r => r.json())
      .then((d: any) => setRows(d.data ?? []))
      .catch(() => {})
      .finally(() => setLoading(false));
  }, []);

  const sorted = [...rows].sort((a, b) => {
    const av = a[sortKey] as any;
    const bv = b[sortKey] as any;
    return sortDir === 'desc' ? (bv > av ? 1 : -1) : (av > bv ? 1 : -1);
  });

  function toggleSort(key: keyof DicomUsageRow) {
    if (sortKey === key) setSortDir(d => d === 'desc' ? 'asc' : 'desc');
    else { setSortKey(key); setSortDir('desc'); }
  }

  return (
    <Card>
      <CardHeader>
        <CardTitle className="flex items-center gap-2">
          <Brain className="h-5 w-5 text-violet-500" />
          DICOM AI Usage
        </CardTitle>
        <CardDescription>Per-clinic quota consumption for the current billing period. Use Langfuse for per-run cost analysis.</CardDescription>
      </CardHeader>
      <CardContent>
        {loading ? (
          <p className="text-sm text-muted-foreground">Loading...</p>
        ) : rows.length === 0 ? (
          <p className="text-sm text-muted-foreground">No DICOM AI usage recorded yet.</p>
        ) : (
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead>Clinic</TableHead>
                <TableHead className="cursor-pointer" onClick={() => toggleSort('monthly_studies')}>
                  Studies {sortKey === 'monthly_studies' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
                </TableHead>
                <TableHead className="cursor-pointer" onClick={() => toggleSort('daily_studies')}>
                  Today {sortKey === 'daily_studies' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
                </TableHead>
                <TableHead>Tokens</TableHead>
                <TableHead>Period Start</TableHead>
                <TableHead>Terms</TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {sorted.map(r => (
                <TableRow key={r.clinic_id}>
                  <TableCell className="font-medium">{r.clinic_name}</TableCell>
                  <TableCell><UsageBar used={r.monthly_studies} limit={500} /></TableCell>
                  <TableCell><UsageBar used={r.daily_studies} limit={20} /></TableCell>
                  <TableCell>
                    <UsageBar used={r.tokens_input + r.tokens_output} limit={5_000_000} />
                  </TableCell>
                  <TableCell className="text-xs text-muted-foreground">{r.period_start}</TableCell>
                  <TableCell>
                    {r.terms_accepted_at
                      ? <Badge variant="outline" className="text-emerald-600 border-emerald-600/30 text-[10px]">Accepted</Badge>
                      : <Badge variant="outline" className="text-muted-foreground text-[10px]">Pending</Badge>}
                  </TableCell>
                </TableRow>
              ))}
            </TableBody>
          </Table>
        )}
      </CardContent>
    </Card>
  );
}
  • Step 2: Register the view in SuperAdminDashboard.tsx
Add import near the other superadmin imports (around line 22–33):
import DicomUsageMonitor from '../superadmin/DicomUsageMonitor';
Add view render (after line 384 {activeView === 'cron-jobs' && <CronJobsManager />}):
{activeView === 'dicom-usage' && <DicomUsageMonitor />}
Also add the nav item — find the useNavItems hook call or the navItems array and add a dicom-usage entry with a Brain icon in the superadmin nav section. The exact location depends on how useNavItems is structured — search for 'system' or 'cron-jobs' nav items and add alongside:
{ id: 'dicom-usage', label: 'DICOM Usage', icon: ic(Brain) },
  • Step 3: Commit
git add ui/src/components/superadmin/DicomUsageMonitor.tsx ui/src/components/dashboards/SuperAdminDashboard.tsx
git commit -m "feat(superadmin): DICOM AI usage monitor with per-clinic quota table"

Self-Review Checklist

  • Spec §1 (dicom_quota table) → Task 1 + Task 2
  • Spec §1 (clinic_modules columns) → Task 1 + Task 2
  • Spec §2 (all limits) → Task 3 checkQuotaLimits + Task 6 enforcement
  • Spec §3.1 (POST enforcement order) → Task 6 route handler
  • Spec §3.2 (GET quota) → Task 6 GET route
  • Spec §3.3 (POST terms-accept) → Task 6 POST route
  • Spec §4 (DO concurrency slots) → Task 4
  • Spec §5.1 (usage counter) → Task 8 DicomPage
  • Spec §5.2 (terms modal) → Task 8 DicomViewer
  • Spec §5.3 (persistent disclaimer) → Task 8 DicomViewer
  • Spec §5.4 (quota error messages) → Task 8 DicomViewer
  • Spec §5.5 (patient history) → Task 6 recordActivity call
  • Spec §6.1 (superadmin monitor) → Task 7 + Task 9
  • max_completion_tokens + temperature → Task 5
  • type consistency: QuotaRow, QuotaError, DicomUsageRow — all defined once, used consistently
  • no FKs on dicom_quota → confirmed in Task 2 SQL (no REFERENCES clause)
  • DO no alarms, no storage writes → Task 4 uses only in-memory fields