Skip to main content

Bridge TIFF/DICOM: Full-Page Viewer + Server-Side PNG Conversion

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: Convert TIFF and DICOM files to PNG server-side at upload time so Ruby AI can analyse them with zero latency; add a full-page react-tiff viewer for viewing TIFF files in a new tab. Architecture: Upload handler stores the original then fires ctx.waitUntil(convertAndStorePng(...)) which decodes the file (utif2 for TIFF, dicom-parser for DICOM), encodes PNG in pure JS, and saves it to R2. Completion fires an SSE event; BridgeInbox reacts via TanStack Query invalidation. XRayWorkstation downloads the PNG preview instead of the original when conversion_status = 'done'. Tech Stack: Cloudflare Workers, Hono, Drizzle ORM, utif2 (TIFF decode), dicom-parser (already installed), CompressionStream (native CF Workers API for PNG deflate), react-tiff (TIFF viewer), TanStack Query v5.

File Map

ActionPathWhat changes
Modifyserver/src/schema/patient_files.tsAdd conversionStatus, previewKey columns
Createserver/drizzle/0030_add_conversion_columns.sqlMigration
Createserver/src/lib/png-encoder.tsPure-JS PNG encoder (CRC32 + CompressionStream)
Createserver/src/lib/image-conversion.tsconvertAndStorePng() orchestrator (TIFF + DICOM paths)
Modifyserver/src/durable-objects/clinic-hub.tsAdd file_conversion_ready to ClinicEvent type
Modifyserver/src/routes/files.tsTrigger conversion in upload handler; ?preview=true in download
Modifyui/src/lib/queryKeys.tsAdd qk.files keys
Createui/src/pages/TiffViewerPage.tsxFull-page react-tiff viewer
Modifyui/src/App.tsxLazy route /files/tiff-viewer
Modifyui/src/components/files/BridgeInbox.tsxTanStack Query, Processing badge, TIFF viewer link, SSE invalidation
Modifyui/src/components/files/XRayWorkstation.tsxAccept conversion props, use preview URL
Modifyui/src/components/files/FileManager.tsxPass conversion props to XRayWorkstation
Modifyui/src/components/files/DicomPage.tsxPass conversion props to XRayWorkstation

Task 1: Install Dependencies

Files: server/package.json, ui/package.json
  • Install utif2 on the server
cd server && npm install utif2
  • Install react-tiff on the UI
cd ui && npm install react-tiff
  • Commit
git add server/package.json server/package-lock.json ui/package.json ui/package-lock.json
git commit -m "chore: add utif2 (server) and react-tiff (ui) dependencies"

Task 2: DB Schema + Migration

Files:
  • Modify: server/src/schema/patient_files.ts
  • Create: server/drizzle/0030_add_conversion_columns.sql
  • Add columns to patientFiles schema
Open server/src/schema/patient_files.ts. Replace the existing table definition with:
import { pgTable, text, timestamp, pgEnum, integer, date, index, varchar } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { appSchema } from './base';
import { clinics } from './clinics';
import { patients } from './patients';
import { users } from './users';

export const fileTypeEnum = pgEnum('file_type', ['x-ray', 'document', 'image', 'report', 'dicom']);
export const patientFileSourceEnum = appSchema.enum('patient_file_source', ['manual_upload', 'bridge_capture', 'lab_dicom']);

export const patientFiles = appSchema.table('patient_files', {
  id: text('id').primaryKey(),
  clinicId: text('clinic_id').notNull().references(() => clinics.id, { onDelete: 'cascade' }),
  patientId: text('patient_id').references(() => patients.id, { onDelete: 'set null' }),
  fileName: text('file_name').notNull(),
  fileType: fileTypeEnum('file_type').notNull(),
  category: text('category').notNull(),
  filePath: text('file_path').notNull(),
  fileSizeKb: integer('file_size_kb'),
  toothNumber: text('tooth_number'),
  uploadDate: date('upload_date').defaultNow().notNull(),
  uploadedBy: text('uploaded_by').references(() => users.id, { onDelete: 'set null' }),
  notes: text('notes'),
  source: patientFileSourceEnum('source').notNull().default('manual_upload'),
  operatoryRoom: varchar('operatory_room', { length: 64 }),
  conversionStatus: text('conversion_status').notNull().default('none'),
  previewKey: text('preview_key'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
}, (table) => ({
  clinicIdIdx: index('patient_files_clinic_id_idx').on(table.clinicId),
  patientIdIdx: index('patient_files_patient_id_idx').on(table.patientId),
  inboxIdx: index('patient_files_inbox_idx').on(table.clinicId, table.createdAt).where(sql`${table.patientId} IS NULL`),
}));

export type PatientFile = typeof patientFiles.$inferSelect;
export type NewPatientFile = typeof patientFiles.$inferInsert;
  • Write migration SQL
Create server/drizzle/0030_add_conversion_columns.sql:
ALTER TABLE clinic.patient_files
  ADD COLUMN IF NOT EXISTS conversion_status TEXT NOT NULL DEFAULT 'none',
  ADD COLUMN IF NOT EXISTS preview_key TEXT;
  • Run migration against the database
cd server && npm run db:migrate
Expected: migration applies without error.
  • Commit
git add server/src/schema/patient_files.ts server/drizzle/0030_add_conversion_columns.sql
git commit -m "feat(db): add conversion_status and preview_key to patient_files"

Task 3: PNG Encoder Utility

Files:
  • Create: server/src/lib/png-encoder.ts
This module encodes a raw RGBA Uint8Array into a valid PNG file using only built-in CF Workers APIs (CompressionStream for deflate, no native modules).
  • Create the file
server/src/lib/png-encoder.ts:
// CRC-32 table for PNG chunk checksums
const crcTable = new Int32Array(256);
for (let n = 0; n < 256; n++) {
  let c = n;
  for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
  crcTable[n] = c;
}

function crc32(buf: Uint8Array): number {
  let c = 0xFFFFFFFF;
  for (let i = 0; i < buf.length; i++) c = crcTable[(c ^ buf[i]) & 0xFF]! ^ (c >>> 8);
  return (c ^ 0xFFFFFFFF) >>> 0;
}

function makeChunk(type: string, data: Uint8Array): Uint8Array {
  const typeBytes = new TextEncoder().encode(type);
  const out = new Uint8Array(12 + data.length);
  const v = new DataView(out.buffer);
  v.setUint32(0, data.length);
  out.set(typeBytes, 4);
  out.set(data, 8);
  v.setUint32(8 + data.length, crc32(new Uint8Array(out.buffer, 4, 4 + data.length)));
  return out;
}

async function deflateRaw(data: Uint8Array): Promise<Uint8Array> {
  const cs = new CompressionStream('deflate-raw');
  const w = cs.writable.getWriter();
  await w.write(data);
  await w.close();
  const parts: Uint8Array[] = [];
  const r = cs.readable.getReader();
  for (;;) {
    const { done, value } = await r.read();
    if (done) break;
    parts.push(value);
  }
  let len = 0;
  for (const p of parts) len += p.length;
  const out = new Uint8Array(len);
  let off = 0;
  for (const p of parts) { out.set(p, off); off += p.length; }
  return out;
}

/** Nearest-neighbour downscale to fit within maxDim on the longest edge */
export function downscaleRgba(
  rgba: Uint8Array, srcW: number, srcH: number, maxDim = 2048
): { rgba: Uint8Array; width: number; height: number } {
  if (srcW <= maxDim && srcH <= maxDim) return { rgba, width: srcW, height: srcH };
  const scale = maxDim / Math.max(srcW, srcH);
  const dstW = Math.max(1, Math.round(srcW * scale));
  const dstH = Math.max(1, Math.round(srcH * scale));
  const out = new Uint8Array(dstW * dstH * 4);
  for (let y = 0; y < dstH; y++) {
    const sy = Math.min(srcH - 1, Math.round(y / scale));
    for (let x = 0; x < dstW; x++) {
      const sx = Math.min(srcW - 1, Math.round(x / scale));
      const si = (sy * srcW + sx) * 4;
      const di = (y * dstW + x) * 4;
      out[di] = rgba[si]; out[di + 1] = rgba[si + 1];
      out[di + 2] = rgba[si + 2]; out[di + 3] = rgba[si + 3];
    }
  }
  return { rgba: out, width: dstW, height: dstH };
}

/**
 * Encode RGBA pixels as PNG.
 * rgba must be width * height * 4 bytes in R G B A order.
 */
export async function encodePng(width: number, height: number, rgba: Uint8Array): Promise<Uint8Array> {
  // Build filter-0 (None) scanlines
  const rowLen = width * 4 + 1;
  const raw = new Uint8Array(height * rowLen);
  for (let y = 0; y < height; y++) {
    raw[y * rowLen] = 0;
    raw.set(rgba.subarray(y * width * 4, (y + 1) * width * 4), y * rowLen + 1);
  }

  const compressed = await deflateRaw(raw);

  const ihdrData = new Uint8Array(13);
  const dv = new DataView(ihdrData.buffer);
  dv.setUint32(0, width);
  dv.setUint32(4, height);
  ihdrData[8] = 8;  // bit depth
  ihdrData[9] = 6;  // colour type: RGBA
  // bytes 10-12 = 0 (compression, filter, interlace method)

  const sig = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
  const ihdr = makeChunk('IHDR', ihdrData);
  const idat = makeChunk('IDAT', compressed);
  const iend = makeChunk('IEND', new Uint8Array(0));

  let total = sig.length + ihdr.length + idat.length + iend.length;
  const png = new Uint8Array(total);
  let off = 0;
  for (const part of [sig, ihdr, idat, iend]) { png.set(part, off); off += part.length; }
  return png;
}
  • Commit
git add server/src/lib/png-encoder.ts
git commit -m "feat(server): add pure-JS PNG encoder (CompressionStream + CRC32)"

Task 4: Image Conversion Library

Files:
  • Create: server/src/lib/image-conversion.ts
  • Create the conversion orchestrator
server/src/lib/image-conversion.ts:
import { getReadDb } from './db';
import { getR2Service } from './r2';
import { patientFiles } from '../schema';
import { eq } from 'drizzle-orm';
import { publishEvent } from './event-bus';
import { encodePng, downscaleRgba } from './png-encoder';

// TIFF transfer syntaxes that require compressed pixel handling (skip gracefully)
const COMPRESSED_DICOM_UIDS = new Set([
  '1.2.840.10008.1.2.4.50',  // JPEG Baseline
  '1.2.840.10008.1.2.4.51',  // JPEG Extended
  '1.2.840.10008.1.2.4.57',  // JPEG Lossless
  '1.2.840.10008.1.2.4.70',  // JPEG Lossless SV1
  '1.2.840.10008.1.2.4.90',  // JPEG 2000 Lossless
  '1.2.840.10008.1.2.4.91',  // JPEG 2000
]);

const MAX_DIM = 2048;

async function tiffToRgba(buffer: ArrayBuffer): Promise<{ width: number; height: number; rgba: Uint8Array }> {
  // Dynamic import so the module is only loaded when needed
  const UTIF = (await import('utif2')).default;
  const uint8 = new Uint8Array(buffer);
  const ifds = UTIF.decode(uint8);
  if (!ifds.length) throw new Error('No pages in TIFF');
  UTIF.decodeImage(uint8, ifds[0]);
  const rgba = UTIF.toRGBA8(ifds[0]);
  const width: number = (ifds[0] as any).width ?? (ifds[0] as any).t256?.[0];
  const height: number = (ifds[0] as any).height ?? (ifds[0] as any).t257?.[0];
  if (!width || !height) throw new Error('Could not read TIFF dimensions');
  return { width, height, rgba: new Uint8Array(rgba.buffer) };
}

async function dicomToRgba(buffer: ArrayBuffer): Promise<{ width: number; height: number; rgba: Uint8Array }> {
  // Dynamic import
  const dicomParser = (await import('dicom-parser')).default;
  const byteArray = new Uint8Array(buffer);
  const dataSet = dicomParser.parseDicom(byteArray);

  // Reject compressed transfer syntaxes we can't decompress in Workers
  const transferSyntax = dataSet.string('x00020010');
  if (transferSyntax && COMPRESSED_DICOM_UIDS.has(transferSyntax.trim())) {
    throw new Error(`Compressed DICOM transfer syntax not supported: ${transferSyntax}`);
  }

  const height = dataSet.uint16('x00280010');  // rows
  const width  = dataSet.uint16('x00280011');  // columns
  if (!width || !height) throw new Error('Could not read DICOM dimensions');

  const bitsAllocated      = dataSet.uint16('x00280100') ?? 8;
  const pixelRepresentation = dataSet.uint16('x00280103') ?? 0;
  const slope    = parseFloat(dataSet.string('x00281053') ?? '1') || 1;
  const intercept = parseFloat(dataSet.string('x00281052') ?? '0') || 0;

  // Dental bone windowing: centre 700, width 3000
  const winCenter = parseFloat(dataSet.string('x00281050') ?? '700') || 700;
  const winWidth  = parseFloat(dataSet.string('x00281051') ?? '3000') || 3000;
  const winLow    = winCenter - winWidth / 2;

  const pixElem = dataSet.elements.x7fe00010;
  if (!pixElem) throw new Error('No pixel data element in DICOM');

  let pixelData: Uint16Array | Int16Array | Uint8Array;
  if (bitsAllocated === 16) {
    const slice = byteArray.buffer.slice(pixElem.dataOffset, pixElem.dataOffset + pixElem.length);
    pixelData = pixelRepresentation === 1 ? new Int16Array(slice) : new Uint16Array(slice);
  } else {
    pixelData = new Uint8Array(byteArray.buffer, pixElem.dataOffset, pixElem.length);
  }

  // Apply windowing: map HU range → [0, 255] grayscale, expand to RGBA
  const totalPixels = width * height;
  const rgba = new Uint8Array(totalPixels * 4);
  for (let i = 0; i < totalPixels; i++) {
    const hu = (pixelData[i]! * slope) + intercept;
    const g  = Math.max(0, Math.min(255, Math.round(((hu - winLow) / winWidth) * 255)));
    const di = i * 4;
    rgba[di] = g; rgba[di + 1] = g; rgba[di + 2] = g; rgba[di + 3] = 255;
  }
  return { width, height, rgba };
}

type ConversionEnv = {
  R2_STORAGE?: any;
  DATABASE_URL?: string;
  CLINIC_HUB?: any;
  [key: string]: any;
};

export async function convertAndStorePng(
  env: ConversionEnv,
  fileId: string,
  clinicId: string,
  originalKey: string,
  mimeType: string
): Promise<void> {
  const db = getReadDb();
  const r2 = getR2Service(env);
  if (!r2) return;

  const isTiff   = mimeType === 'image/tiff' || mimeType === 'image/tif';
  const isDicom  = mimeType === 'application/dicom';
  if (!isTiff && !isDicom) return;

  const previewKey = `${originalKey}-preview.png`;

  try {
    const r2Obj = await r2.getFile(originalKey);
    if (!r2Obj) throw new Error('Original file not found in R2');
    const buffer = await r2Obj.arrayBuffer();

    const { width, height, rgba } = isTiff
      ? await tiffToRgba(buffer)
      : await dicomToRgba(buffer);

    const { rgba: scaledRgba, width: w, height: h } = downscaleRgba(rgba, width, height, MAX_DIM);
    const pngBytes = await encodePng(w, h, scaledRgba);

    await r2.uploadFile(previewKey, pngBytes, 'image/png', {
      sourceFileId: fileId,
      convertedFrom: mimeType,
    });

    await db.update(patientFiles)
      .set({ conversionStatus: 'done', previewKey })
      .where(eq(patientFiles.id, fileId));

    await publishEvent(env as any, clinicId, {
      type: 'file_conversion_ready',
      fileId,
      status: 'done',
    } as any);

  } catch (err) {
    console.error('[image-conversion] failed for file', fileId, err);
    try {
      await db.update(patientFiles)
        .set({ conversionStatus: 'failed' })
        .where(eq(patientFiles.id, fileId));
      await publishEvent(env as any, clinicId, {
        type: 'file_conversion_ready',
        fileId,
        status: 'failed',
      } as any);
    } catch { /* best-effort */ }
  }
}
  • Commit
git add server/src/lib/image-conversion.ts
git commit -m "feat(server): add convertAndStorePng — TIFF and DICOM to PNG via utif2/dicom-parser"

Task 5: ClinicEvent Type + Upload Handler + Download Endpoint

Files:
  • Modify: server/src/durable-objects/clinic-hub.ts
  • Modify: server/src/routes/files.ts
  • Add file_conversion_ready to ClinicEvent
In server/src/durable-objects/clinic-hub.ts, update the ClinicEvent interface:
export interface ClinicEvent {
  type: 'xray_uploaded' | 'new_xray' | 'new_dicom' | 'new_lab_attachment' | 'lab_status_update' | 'ping' | 'do_reset' | 'file_conversion_ready';
  patientId?: string;
  patientName?: string;
  fileId?: string;
  fileName?: string;
  toothNumber?: string | null;
  source?: string;
  labCaseId?: string | null;
  status?: string;
  ts: number;
}
  • Update upload handler to set conversionStatus and trigger conversion
In server/src/routes/files.ts, add the import at the top alongside the other imports:
import { convertAndStorePng } from '../lib/image-conversion';
The upload handler already has these variables at the relevant lines:
  • isDicom (line ~207): const isDicom = fileType.toLowerCase() === 'dicom';
  • contentType (line ~234): const contentType = file.type || 'application/octet-stream';
Add the following right before the db.insert call (line ~268):
const needsConversion = isDicom ||
  contentType === 'image/tiff' || contentType === 'image/tif' ||
  file.name.toLowerCase().endsWith('.tiff') || file.name.toLowerCase().endsWith('.tif');
Inside the .values({...}) block of the insert, add one line after operatoryRoom: operatoryRoom || null,:
conversionStatus: needsConversion ? 'pending' : 'none',
After the existing DICOM async block (after line ~294, inside the if (isDicom) { ... } close), add:
// Kick off PNG conversion for TIFF and DICOM files (non-blocking)
if (needsConversion) {
  const convPromise = convertAndStorePng(
    env, newFile.id, clinicId, r2Key,
    isDicom ? 'application/dicom' : contentType
  );
  if ((c as any).executionCtx?.waitUntil) {
    (c as any).executionCtx.waitUntil(convPromise);
  } else {
    convPromise.catch(() => {});
  }
}
  • Extend download endpoint to serve preview PNG
In server/src/routes/files.ts, find the download handler filesRoute.get('/:id/download', ...). After the authorization check (around line 474), add the preview branch before the main R2 fetch:
// Serve preview PNG when requested and available
const wantsPreview = c.req.query('preview') === 'true';
if (wantsPreview) {
  if (!file.previewKey) {
    throw new AppError('Preview not available for this file', 404);
  }
  const previewObj = await r2Service.getFile(file.previewKey);
  if (!previewObj) throw new AppError('Preview file not found in storage', 404);
  const previewBody = (previewObj as any).body ?? await previewObj.arrayBuffer();
  return new Response(previewBody, {
    headers: {
      'Content-Type': 'image/png',
      'Content-Disposition': `inline; filename="${encodeURIComponent(file.fileName.replace(/\.[^.]+$/, ''))}-preview.png"`,
      'Cache-Control': 'private, max-age=86400',
    },
  });
}
  • Verify the server builds
cd server && npx tsc --noEmit
Expected: no type errors.
  • Commit
git add server/src/durable-objects/clinic-hub.ts server/src/routes/files.ts
git commit -m "feat(server): trigger PNG conversion at upload; add ?preview=true download param"

Task 6: Query Keys + TiffViewerPage

Files:
  • Modify: ui/src/lib/queryKeys.ts
  • Create: ui/src/pages/TiffViewerPage.tsx
  • Add files query keys
In ui/src/lib/queryKeys.ts, add a files section inside the qk object (alongside patients, appointments, etc.):
files: {
  bridgeInbox: () => ['files', clinicScope(), 'bridge-inbox'] as const,
  detail: (id: string) => ['files', clinicScope(), 'detail', id] as const,
},
  • Create TiffViewerPage
ui/src/pages/TiffViewerPage.tsx:
import { useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { TIFFViewer } from 'react-tiff';
import 'react-tiff/dist/index.css';
import { downloadFileAsBlob } from '@/lib/serverComm';
import { qk } from '@/lib/queryKeys';
import { Loader2, AlertTriangle } from 'lucide-react';

export default function TiffViewerPage() {
  const [params] = useSearchParams();
  const fileId = params.get('id') ?? '';
  const blobUrlRef = useRef<string | null>(null);

  const { data: blobUrl, isLoading, isError } = useQuery({
    queryKey: qk.files.detail(fileId),
    queryFn: async () => {
      const blob = await downloadFileAsBlob(`/api/v1/protected/files/${fileId}/download`);
      const url = URL.createObjectURL(blob);
      blobUrlRef.current = url;
      return url;
    },
    enabled: Boolean(fileId),
    staleTime: 5 * 60 * 1000,
    gcTime: 10 * 60 * 1000,
  });

  useEffect(() => {
    return () => {
      if (blobUrlRef.current) URL.revokeObjectURL(blobUrlRef.current);
    };
  }, []);

  if (!fileId) {
    return (
      <div className="flex items-center justify-center h-screen">
        <p className="text-muted-foreground">No file specified.</p>
      </div>
    );
  }

  if (isLoading) {
    return (
      <div className="flex items-center justify-center h-screen gap-2 text-muted-foreground">
        <Loader2 className="h-5 w-5 animate-spin" />
        <span>Loading TIFF…</span>
      </div>
    );
  }

  if (isError || !blobUrl) {
    return (
      <div className="flex flex-col items-center justify-center h-screen gap-3 text-destructive">
        <AlertTriangle className="h-8 w-8" />
        <p>Failed to load image. Please close this tab and try again.</p>
      </div>
    );
  }

  return (
    <div className="min-h-screen bg-background flex flex-col">
      <TIFFViewer
        tiff={blobUrl}
        lang="en"
        paginate="ltr"
        zoomable
        printable
      />
    </div>
  );
}
  • Commit
git add ui/src/lib/queryKeys.ts ui/src/pages/TiffViewerPage.tsx
git commit -m "feat(ui): add files query keys and TiffViewerPage with react-tiff"

Task 7: App.tsx Route

Files:
  • Modify: ui/src/App.tsx
  • Add lazy import
At the top of ui/src/App.tsx where other lazy imports are, add:
const TiffViewerPage = lazy(() => import('@/pages/TiffViewerPage'));
  • Add route
Inside the <Routes> block in App.tsx, add this route in the public/unauthenticated section (the viewer authenticates via the session cookie, just like the rest of the SPA — place it alongside other authenticated routes):
<Route path="/files/tiff-viewer" element={<TiffViewerPage />} />
Place it near the other file-related routes (e.g., near /dental-chart).
  • Commit
git add ui/src/App.tsx
git commit -m "feat(ui): add /files/tiff-viewer route"

Task 8: BridgeInbox — TanStack Query + Processing Badge + TIFF Viewer

Files:
  • Modify: ui/src/components/files/BridgeInbox.tsx
  • Update BridgeFile interface
Add conversionStatus and previewKey to the BridgeFile interface at the top of the file:
interface BridgeFile {
  id: string;
  fileName: string;
  fileType: string;
  category: string;
  filePath: string;
  fileSizeKb: number | null;
  uploadDate: string;
  createdAt: string;
  fileUrl: string;
  conversionStatus: 'none' | 'pending' | 'done' | 'failed';
  previewKey: string | null;
}
  • Add TanStack Query imports
Add to the import block:
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { qk } from '@/lib/queryKeys';
  • Replace manual load() with useQuery
Remove the existing load function, the useState for files/loading/refreshing, and their useEffect blocks. Replace with:
const queryClient = useQueryClient();

const { data: files = [], isLoading: loading, isFetching: refreshing } = useQuery({
  queryKey: qk.files.bridgeInbox(),
  queryFn: async () => {
    const res = await fetchWithAuth('/api/v1/protected/files/bridge-inbox');
    const data = await res.json();
    return Array.isArray(data) ? (data as BridgeFile[]) : [];
  },
  refetchInterval: 15_000,
  staleTime: 10_000,
});
  • Handle SSE events via query invalidation
Replace both existing useEffect blocks that react to lastEvent with a single one:
useEffect(() => {
  if (!lastEvent) return;
  if (
    (lastEvent.type === 'xray_uploaded' && lastEvent.source === 'bridge_capture') ||
    lastEvent.type === 'file_conversion_ready'
  ) {
    queryClient.invalidateQueries({ queryKey: qk.files.bridgeInbox() });
  }
}, [lastEvent, queryClient]);
  • Update ThumbnailContent — add Processing/Unavailable badges
The ThumbnailContent component currently renders a static icon for TIFF. Update the outer card rendering (where ThumbnailContent is called) to overlay a status badge when conversion is in progress or failed. Find the card JSX that renders each file and add a badge overlay. Look for where <ThumbnailContent file={file} kind={kind} /> is rendered, and wrap it like this:
<div className="relative">
  <ThumbnailContent file={file} kind={kind} />
  {file.conversionStatus === 'pending' && (
    <div className="absolute bottom-0 inset-x-0 flex items-center justify-center pb-1">
      <span className="text-[9px] font-semibold bg-amber-500/90 text-white rounded px-1.5 py-0.5 leading-none">
        Processing…
      </span>
    </div>
  )}
  {file.conversionStatus === 'failed' && (
    <div className="absolute bottom-0 inset-x-0 flex items-center justify-center pb-1">
      <span className="text-[9px] font-semibold bg-red-500/90 text-white rounded px-1.5 py-0.5 leading-none">
        Unavailable
      </span>
    </div>
  )}
</div>
  • Update TIFF eye/preview click to open TiffViewerPage
Find openPreview function. Update it to open the TIFF viewer in a new tab for TIFF files:
const openPreview = (file: BridgeFile) => {
  const kind = getFileKind(file);
  if (kind === 'dicom') {
    setDicomFile(file);
  } else if (kind === 'tiff') {
    window.open(`/files/tiff-viewer?id=${file.id}`, '_blank', 'noopener');
  } else {
    setPreviewFile(file);
  }
};
  • Commit
git add ui/src/components/files/BridgeInbox.tsx
git commit -m "feat(ui): BridgeInbox — TanStack Query, conversion badges, TIFF viewer link"

Task 9: XRayWorkstation — Use PNG Preview When Available

Files:
  • Modify: ui/src/components/files/XRayWorkstation.tsx
  • Modify: ui/src/components/files/FileManager.tsx
  • Modify: ui/src/components/files/DicomPage.tsx
  • Add conversion props to XRayWorkstationProps
In XRayWorkstation.tsx, update the props interface:
interface XRayWorkstationProps {
  fileId: string;
  fileName: string;
  conversionStatus?: 'none' | 'pending' | 'done' | 'failed';
  previewKey?: string | null;
  onClose: () => void;
}
Update the function signature:
export default function XRayWorkstation({ fileId, fileName, conversionStatus, previewKey, onClose }: XRayWorkstationProps) {
  • Use preview URL when conversion is done
In the useEffect that calls downloadFileAsBlob, change the download URL:
const downloadUrl = conversionStatus === 'done' && previewKey
  ? `/api/v1/protected/files/${fileId}/download?preview=true`
  : `/api/v1/protected/files/${fileId}/download`;

downloadFileAsBlob(downloadUrl)
  .then(blob => { ... });
  • Disable Ruby AI button when conversion is pending
Find the button that triggers Ruby analysis (look for onClick={runAnalysis} or similar). Disable it when conversionStatus === 'pending' and show a tooltip:
<Button
  onClick={() => { /* existing handler */ }}
  disabled={runningAI || conversionStatus === 'pending'}
  title={conversionStatus === 'pending' ? 'Preparing image for AI analysis…' : undefined}
>
  {/* existing content */}
</Button>
  • Show error banner when conversion failed
Inside the Ruby AI panel section, before the main button area, add:
{conversionStatus === 'failed' && (
  <div className="flex items-center gap-2 p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
    <AlertTriangle className="h-4 w-4 flex-shrink-0" />
    Image could not be prepared for AI analysis. Please re-upload the file.
  </div>
)}
  • Pass conversion props in FileManager
In FileManager.tsx, find:
<XRayWorkstation
  fileId={selectedFile.id}
  fileName={selectedFile.fileName}
  onClose={...}
/>
Update to:
<XRayWorkstation
  fileId={selectedFile.id}
  fileName={selectedFile.fileName}
  conversionStatus={(selectedFile as any).conversionStatus}
  previewKey={(selectedFile as any).previewKey}
  onClose={...}
/>
  • Pass conversion props in DicomPage
Find the <XRayWorkstation usage in DicomPage.tsx and add the same two props:
conversionStatus={(selectedFile as any).conversionStatus}
previewKey={(selectedFile as any).previewKey}
  • Commit
git add ui/src/components/files/XRayWorkstation.tsx ui/src/components/files/FileManager.tsx ui/src/components/files/DicomPage.tsx
git commit -m "feat(ui): XRayWorkstation uses PNG preview when conversion_status=done"

Task 10: XRayRubyPanel — Remove TIFF Block, Use Preview URL

Files:
  • Modify: ui/src/components/files/XRayRubyPanel.tsx
This is the panel used in contexts other than the full XRayWorkstation (e.g., patient detail). It also needs to use the preview URL.
  • Add conversion props
Update XRayRubyPanelProps:
interface XRayRubyPanelProps {
  fileId: string;
  conversionStatus?: 'none' | 'pending' | 'done' | 'failed';
  previewKey?: string | null;
}
Update function signature:
export function XRayRubyPanel({ fileId, conversionStatus, previewKey }: XRayRubyPanelProps) {
  • Remove TIFF rejection, use preview URL in runAnalysis
In runAnalysis, replace:
const blob = await downloadFileAsBlob(`/api/v1/protected/files/${fileId}/download`);
if (isTiff('', blob.type)) {
  throw new Error('TIFF files are not supported by Ruby AI. Please convert the image to JPEG or PNG first.');
}
const mimeType = blob.type || 'image/jpeg';
const imageBase64 = await imageBlobToBase64(blob);
With:
const downloadUrl = conversionStatus === 'done' && previewKey
  ? `/api/v1/protected/files/${fileId}/download?preview=true`
  : `/api/v1/protected/files/${fileId}/download`;
const blob = await downloadFileAsBlob(downloadUrl);
const mimeType = 'image/png'; // preview is always PNG; raw JPG/PNG stays as-is
const imageBase64 = await imageBlobToBase64(blob);
  • Disable button and show status
In the JSX, find the “Run Analysis” button and make it aware of conversion state:
{conversionStatus === 'failed' && (
  <div className="flex items-center gap-2 p-3 rounded-lg bg-destructive/10 text-destructive text-sm mb-3">
    <AlertTriangle className="h-4 w-4 flex-shrink-0" />
    Image could not be prepared for AI analysis.
  </div>
)}

<Button
  onClick={runAnalysis}
  disabled={running || conversionStatus === 'pending' || conversionStatus === 'failed'}
  title={conversionStatus === 'pending' ? 'Preparing image…' : undefined}
>
  {conversionStatus === 'pending' ? 'Preparing image…' : 'Run Analysis'}
</Button>
  • Remove unused isTiff import if no other usage
Check if isTiff is still used elsewhere in the file. If not, remove:
import { isTiff } from '@/lib/tiff-utils';
  • Commit
git add ui/src/components/files/XRayRubyPanel.tsx
git commit -m "feat(ui): XRayRubyPanel removes TIFF block, uses server-side PNG preview"

Task 11: Final Verification

  • TypeScript check — server
cd server && npx tsc --noEmit
Expected: no errors.
  • TypeScript check — UI
cd ui && npx tsc --noEmit
Expected: no errors.
  • Manual smoke test — TIFF upload via Bridge
  1. Upload a .tiff file from Bridge (or simulate via the upload endpoint directly)
  2. Open Bridge Inbox — card should show Processing… amber badge
  3. Wait ~5 seconds — badge should disappear (SSE event fires on completion)
  4. Click eye icon — TIFF viewer page opens in new tab
  5. Click Ruby AI — analysis runs using the PNG preview
  • Manual smoke test — DICOM upload
  1. Upload a .dcm file
  2. Bridge Inbox shows Processing… badge, then clears
  3. Open XRayWorkstation — Ruby AI button is active (not disabled)
  4. Run analysis — succeeds without client-side DWV conversion
  • Check R2 for preview file
Verify {originalKey}-preview.png exists alongside the original in R2 after conversion.