Skip to main content

OdontoX Bridge — X-ray Integration 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: Build a standalone Electron bridge app in bridge/ that watches an operatory folder and uploads X-ray images in real time to OdontoX, backed by server-side schema migrations, a Durable Object SSE bus, and four frontend module integrations. Architecture: Three layers working together — (1) the bridge/ Electron app watches a local folder, queues uploads in SQLite with offline resilience, and authenticates via a long-lived clinic API key JWT; (2) the server gains a schema migration, a bridge API key endpoint, a Durable Object-backed SSE bus, and an updated upload handler that fires events after each insert; (3) the React frontend gains a global SSE hook, a live X-ray toast, a tooth badge in the dental chart, a live panel in appointment detail, X-ray attachment in clinical notes, and a DICOM ZIP uploader in lab cases. Tech Stack: Electron 33, chokidar 4, better-sqlite3 14, electron-store 10, electron-updater 6, electron-builder 25, axios (bridge); Hono streamSSE + Cloudflare Durable Objects (SSE bus); Drizzle ORM + PostgreSQL (schema); React + EventSource API + sonner toasts (frontend).

File Map

New: bridge/ (Electron app)

FilePurpose
bridge/package.jsonElectron app dependencies and build scripts
bridge/tsconfig.jsonTypeScript config for main + renderer
bridge/electron-builder.config.jsNSIS/DMG packaging config
bridge/src/main/index.tsElectron entry — app lifecycle, tray mount
bridge/src/main/store.tselectron-store typed config (apiKey, serverUrl, watchFolder, roomId, selectedPatient)
bridge/src/main/queue.tsSQLite queue with pending/done/failed states
bridge/src/main/watcher.tschokidar folder watcher → enqueue
bridge/src/main/uploader.tsflush queue → POST /files/upload, retry, hash dedup
bridge/src/main/tray.tsTray icon state machine (green/yellow/red), menu
bridge/src/main/preload.tscontextBridge IPC for patient selector renderer
bridge/src/renderer/patient-selector/index.htmlPatient search window HTML
bridge/src/renderer/patient-selector/app.tsRenderer JS: search input → IPC → select
bridge/assets/tray-green.png16×16 tray icon (connected)
bridge/assets/tray-yellow.png16×16 tray icon (offline/queued)
bridge/assets/tray-red.png16×16 tray icon (error)
bridge/assets/icon.png512×512 app icon

Modified: server/

FileChange
server/src/schema/patient_files.tsAdd source pgEnum + operatoryRoom varchar columns
server/src/schema/clinical_notes.tsAdd attachedFileIds jsonb column
server/src/schema/api_keys.tsNew: bridge API key table
server/src/schema/index.tsExport api_keys
server/src/durable-objects/clinic-hub.tsNew: Durable Object for SSE event bus
server/src/lib/event-bus.tsNew: helper to publish events via DO or in-memory
server/src/routes/clinic-api-keys.tsNew: POST/DELETE /clinic/api-keys
server/src/routes/sse.tsNew: GET /sse/clinic-events (streamSSE)
server/src/routes/files.tsAccept source/operatoryRoom/bridge tokens, fire event after insert
server/src/api.tsMount new routes; export ClinicHub DO class
server/src/worker.tsExport ClinicHub for CF Workers binding
server/wrangler.tomlAdd [durable_objects] binding + [[migrations]]
server/src/lib/nextauth.tsAdd generateBridgeToken + verifyBridgeToken

Modified: ui/

FileChange
ui/src/contexts/ClinicEventsContext.tsxNew: EventSource context + emit/on API
ui/src/hooks/useClinicEvents.tsNew: mount SSE, dispatch to context
ui/src/components/notifications/XrayToast.tsxNew: live X-ray toast
ui/src/App.tsxMount useClinicEvents() + <XrayToast /> inside auth scope
ui/src/components/dental/OdontogramChart.tsxAdd X-ray badge dots on teeth that have files
ui/src/components/appointments/AppointmentDetailPage.tsxAdd live X-ray panel for in_progress appointments
ui/src/components/doctor/LabCaseDetailView.tsxAdd Radiology Files section with DICOM ZIP upload

Task 1 — Schema Migration: patient_files + clinical_notes

Files:
  • Modify: server/src/schema/patient_files.ts
  • Modify: server/src/schema/clinical_notes.ts
  • Step 1: Add source enum and columns to patient_files
// server/src/schema/patient_files.ts
import { pgTable, text, timestamp, pgEnum, integer, date, index, varchar } from 'drizzle-orm/pg-core';
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 = pgEnum('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').notNull().references(() => patients.id, { onDelete: 'cascade' }),
  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 }),
  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),
}));

export type PatientFile = typeof patientFiles.$inferSelect;
export type NewPatientFile = typeof patientFiles.$inferInsert;
  • Step 2: Add attachedFileIds to clinical_notes
// server/src/schema/clinical_notes.ts — add import and column
import { pgTable, text, timestamp, boolean, date, index, jsonb } from 'drizzle-orm/pg-core';
// (all existing imports remain)

export const clinicalNotes = appSchema.table('clinical_notes', {
  // ...existing columns unchanged...
  id: text('id').primaryKey(),
  clinicId: text('clinic_id').notNull().references(() => clinics.id, { onDelete: 'cascade' }),
  patientId: text('patient_id').notNull().references(() => patients.id, { onDelete: 'cascade' }),
  appointmentId: text('appointment_id').references(() => appointments.id, { onDelete: 'set null' }),
  admissionId: text('admission_id'),
  doctorId: text('doctor_id').references(() => users.id, { onDelete: 'set null' }),
  visitDate: date('visit_date').notNull(),
  chiefComplaint: text('chief_complaint'),
  diagnosis: text('diagnosis'),
  treatmentProvided: text('treatment_provided'),
  prescriptionsGiven: text('prescriptions_given'),
  followUpRequired: boolean('follow_up_required').default(false).notNull(),
  followUpDate: date('follow_up_date'),
  notes: text('notes'),
  attachedFileIds: jsonb('attached_file_ids').$type<string[]>().default([]),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => ({
  clinicIdIdx: index('clinical_notes_clinic_id_idx').on(table.clinicId),
  patientIdIdx: index('clinical_notes_patient_id_idx').on(table.patientId),
}));

export type ClinicalNote = typeof clinicalNotes.$inferSelect;
export type NewClinicalNote = typeof clinicalNotes.$inferInsert;
  • Step 3: Push schema to DB
cd server && pnpm db:push
Expected: migrations applied with no errors. Verify with \d app.patient_files — should show source and operatory_room columns. \d app.clinical_notes should show attached_file_ids.
  • Step 4: Commit
git add server/src/schema/patient_files.ts server/src/schema/clinical_notes.ts
git commit -m "feat(schema): add source/operatoryRoom to patient_files, attachedFileIds to clinical_notes"

Task 2 — API Keys: Schema + Endpoint

Files:
  • Create: server/src/schema/api_keys.ts
  • Modify: server/src/schema/index.ts
  • Create: server/src/routes/clinic-api-keys.ts
  • Modify: server/src/lib/nextauth.ts
  • Step 1: Create api_keys schema
// server/src/schema/api_keys.ts
import { pgTable, text, timestamp, index } from 'drizzle-orm/pg-core';
import { appSchema } from './base';
import { clinics } from './clinics';

export const apiKeys = appSchema.table('api_keys', {
  id: text('id').primaryKey(),
  clinicId: text('clinic_id').notNull().references(() => clinics.id, { onDelete: 'cascade' }),
  label: text('label').notNull(),
  keyId: text('key_id').notNull().unique(),
  scope: text('scope').notNull().default('bridge_upload'),
  revokedAt: timestamp('revoked_at'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
}, (table) => ({
  clinicIdIdx: index('api_keys_clinic_id_idx').on(table.clinicId),
  keyIdIdx: index('api_keys_key_id_idx').on(table.keyId),
}));

export type ApiKey = typeof apiKeys.$inferSelect;
  • Step 2: Export from schema index
In server/src/schema/index.ts, after the last export * line, add:
export * from './api_keys';
  • Step 3: Add bridge token helpers to nextauth.ts
Append to server/src/lib/nextauth.ts:
export interface BridgeTokenPayload {
  sub: string;       // clinicId
  keyId: string;
  scope: 'bridge_upload';
  type: 'bridge';
}

export async function generateBridgeToken(clinicId: string, keyId: string): Promise<string> {
  const secret = new TextEncoder().encode(process.env.JWT_SECRET || '');
  const token = await new SignJWT({ sub: clinicId, keyId, scope: 'bridge_upload', type: 'bridge' })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('1y')
    .sign(secret);
  return token;
}

export async function verifyBridgeToken(token: string): Promise<BridgeTokenPayload | null> {
  try {
    const secret = new TextEncoder().encode(process.env.JWT_SECRET || '');
    const { payload } = await jwtVerify(token, secret, { clockTolerance: 60 });
    if (payload.type !== 'bridge' || payload.scope !== 'bridge_upload') return null;
    return payload as unknown as BridgeTokenPayload;
  } catch {
    return null;
  }
}
Note: SignJWT and jwtVerify are already imported from jose in nextauth.ts — check the existing imports and add only what’s missing.
  • Step 4: Create clinic-api-keys route
// server/src/routes/clinic-api-keys.ts
import { Hono } from 'hono';
import { getDatabase } from '../lib/db';
import { getDatabaseUrl } from '../lib/env';
import { handleError, AppError } from '../lib/errors';
import { apiKeys } from '../schema';
import { eq, and } from 'drizzle-orm';
import { generateBridgeToken } from '../lib/nextauth';

const clinicApiKeysRoute = new Hono();

// POST /api/v1/protected/clinic/api-keys — generate a bridge key
clinicApiKeysRoute.post('/', async (c) => {
  try {
    const user = c.get('user');
    const clinicContext = c.get('clinicContext');
    const clinicId = clinicContext?.currentClinicId || user.clinicId || '';

    if (!clinicId) throw new AppError('No clinic context', 403);
    if (user.role !== 'admin' && user.role !== 'superadmin') {
      throw new AppError('Only admins can generate bridge API keys', 403);
    }

    const { label } = await c.req.json<{ label: string }>();
    if (!label?.trim()) throw new AppError('Label is required', 400);

    const db = await getDatabase(getDatabaseUrl());
    const keyId = crypto.randomUUID();
    const token = await generateBridgeToken(clinicId, keyId);

    await db.insert(apiKeys).values({
      id: crypto.randomUUID(),
      clinicId,
      label: label.trim(),
      keyId,
      scope: 'bridge_upload',
    });

    return c.json({ apiKey: token, keyId, label: label.trim() }, 201);
  } catch (error) {
    return handleError(error, c);
  }
});

// GET /api/v1/protected/clinic/api-keys — list keys for clinic
clinicApiKeysRoute.get('/', async (c) => {
  try {
    const user = c.get('user');
    const clinicContext = c.get('clinicContext');
    const clinicId = clinicContext?.currentClinicId || user.clinicId || '';

    if (!clinicId) throw new AppError('No clinic context', 403);
    if (user.role !== 'admin' && user.role !== 'superadmin') {
      throw new AppError('Only admins can view bridge API keys', 403);
    }

    const db = await getDatabase(getDatabaseUrl());
    const keys = await db.select({
      id: apiKeys.id,
      keyId: apiKeys.keyId,
      label: apiKeys.label,
      scope: apiKeys.scope,
      revokedAt: apiKeys.revokedAt,
      createdAt: apiKeys.createdAt,
    })
      .from(apiKeys)
      .where(eq(apiKeys.clinicId, clinicId));

    return c.json(keys);
  } catch (error) {
    return handleError(error, c);
  }
});

// DELETE /api/v1/protected/clinic/api-keys/:keyId — revoke a key
clinicApiKeysRoute.delete('/:keyId', async (c) => {
  try {
    const user = c.get('user');
    const clinicContext = c.get('clinicContext');
    const clinicId = clinicContext?.currentClinicId || user.clinicId || '';
    const { keyId } = c.req.param();

    if (!clinicId) throw new AppError('No clinic context', 403);
    if (user.role !== 'admin' && user.role !== 'superadmin') {
      throw new AppError('Only admins can revoke bridge API keys', 403);
    }

    const db = await getDatabase(getDatabaseUrl());
    const [updated] = await db
      .update(apiKeys)
      .set({ revokedAt: new Date() })
      .where(and(eq(apiKeys.keyId, keyId), eq(apiKeys.clinicId, clinicId)))
      .returning();

    if (!updated) throw new AppError('Key not found', 404);

    return c.json({ revoked: true });
  } catch (error) {
    return handleError(error, c);
  }
});

export default clinicApiKeysRoute;
  • Step 5: Push schema to DB
cd server && pnpm db:push
Expected: api_keys table created in app schema.
  • Step 6: Commit
git add server/src/schema/api_keys.ts server/src/schema/index.ts \
        server/src/routes/clinic-api-keys.ts server/src/lib/nextauth.ts
git commit -m "feat(api-keys): bridge API key generation and revocation"

Task 3 — Durable Object Event Bus + SSE Endpoint

Files:
  • Create: server/src/durable-objects/clinic-hub.ts
  • Create: server/src/lib/event-bus.ts
  • Create: server/src/routes/sse.ts
  • Modify: server/wrangler.toml
  • Step 1: Create ClinicHub Durable Object
// server/src/durable-objects/clinic-hub.ts

export interface ClinicEvent {
  type: 'new_xray' | 'new_dicom' | 'ping';
  patientId?: string;
  patientName?: string;
  fileId?: string;
  toothNumber?: string | null;
  source?: string;
  labCaseId?: string | null;
  ts: number;
}

interface StoredEvent {
  ts: number;
  data: ClinicEvent;
}

export class ClinicHub {
  private events: StoredEvent[] = [];

  constructor(private state: DurableObjectState) {}

  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname.endsWith('/publish') && request.method === 'POST') {
      const event = await request.json<ClinicEvent>();
      const now = Date.now();
      this.events.push({ ts: now, data: { ...event, ts: now } });
      // Keep only last 2 minutes of events
      this.events = this.events.filter(e => now - e.ts < 120_000);
      return new Response('OK', { status: 200 });
    }

    if (url.pathname.endsWith('/poll') && request.method === 'GET') {
      const since = Number(url.searchParams.get('since') || 0);
      const fresh = this.events.filter(e => e.ts > since).map(e => e.data);
      return Response.json({ events: fresh, serverTs: Date.now() });
    }

    return new Response('Not found', { status: 404 });
  }
}
  • Step 2: Create event-bus helper
// server/src/lib/event-bus.ts

import type { ClinicEvent } from '../durable-objects/clinic-hub';

export type { ClinicEvent };

// In-memory fallback for Node.js dev
const memListeners = new Map<string, Set<(e: ClinicEvent) => void>>();

export const memBus = {
  subscribe(clinicId: string, fn: (e: ClinicEvent) => void) {
    if (!memListeners.has(clinicId)) memListeners.set(clinicId, new Set());
    memListeners.get(clinicId)!.add(fn);
    return () => memListeners.get(clinicId)?.delete(fn);
  },
  publish(clinicId: string, event: ClinicEvent) {
    memListeners.get(clinicId)?.forEach(fn => fn(event));
  },
};

export async function publishEvent(
  env: { CLINIC_HUB?: DurableObjectNamespace },
  clinicId: string,
  event: Omit<ClinicEvent, 'ts'>
) {
  const full: ClinicEvent = { ...event, ts: Date.now() };

  if (env.CLINIC_HUB) {
    const id = env.CLINIC_HUB.idFromName(clinicId);
    const hub = env.CLINIC_HUB.get(id);
    await hub.fetch(new Request('https://do/publish', {
      method: 'POST',
      body: JSON.stringify(full),
      headers: { 'Content-Type': 'application/json' },
    }));
  } else {
    memBus.publish(clinicId, full);
  }
}
  • Step 3: Create SSE route
// server/src/routes/sse.ts
import { Hono } from 'hono';
import { streamSSE } from 'hono/streaming';
import { handleError } from '../lib/errors';
import { memBus } from '../lib/event-bus';

const sseRoute = new Hono();

sseRoute.get('/clinic-events', async (c) => {
  try {
    const user = c.get('user');
    const clinicContext = c.get('clinicContext');
    const clinicId = clinicContext?.currentClinicId || user.clinicId || '';

    if (!clinicId) return c.json({ error: 'No clinic context' }, 403);

    const env = c.env as { CLINIC_HUB?: DurableObjectNamespace };

    return streamSSE(c, async (stream) => {
      if (env.CLINIC_HUB) {
        // CF Workers path: poll the Durable Object
        const doId = env.CLINIC_HUB.idFromName(clinicId);
        const hub = env.CLINIC_HUB.get(doId);
        let lastTs = Date.now();

        while (!stream.closed) {
          try {
            const res = await hub.fetch(
              new Request(`https://do/poll?since=${lastTs}`, { method: 'GET' })
            );
            const { events, serverTs } = await res.json<{ events: any[]; serverTs: number }>();
            for (const event of events) {
              await stream.writeSSE({ data: JSON.stringify(event) });
            }
            lastTs = serverTs;
          } catch {}
          await stream.sleep(1000);
        }
      } else {
        // Node.js dev path: subscribe to in-memory bus
        const pending: any[] = [];
        const unsub = memBus.subscribe(clinicId, (event) => pending.push(event));

        // Keepalive ping every 25s
        const timer = setInterval(async () => {
          if (!stream.closed) {
            await stream.writeSSE({ data: JSON.stringify({ type: 'ping', ts: Date.now() }) });
          }
        }, 25_000);

        while (!stream.closed) {
          while (pending.length > 0) {
            await stream.writeSSE({ data: JSON.stringify(pending.shift()) });
          }
          await stream.sleep(200);
        }

        unsub();
        clearInterval(timer);
      }
    });
  } catch (error) {
    return handleError(error, c);
  }
});

export default sseRoute;
  • Step 4: Add Durable Object binding to wrangler.toml
Open server/wrangler.toml. After the [ai] section (around line 50), add:
# Durable Object for clinic real-time event bus (SSE)
[durable_objects]
bindings = [{ name = "CLINIC_HUB", class_name = "ClinicHub" }]

[[migrations]]
tag = "v1-clinic-hub"
new_classes = ["ClinicHub"]
Also add the same under [env.production]:
[env.production.durable_objects]
bindings = [{ name = "CLINIC_HUB", class_name = "ClinicHub" }]
  • Step 5: Commit
git add server/src/durable-objects/clinic-hub.ts server/src/lib/event-bus.ts \
        server/src/routes/sse.ts server/wrangler.toml
git commit -m "feat(sse): Durable Object event bus + SSE endpoint for real-time X-ray events"

Task 4 — Modify Upload Endpoint + Mount New Routes

Files:
  • Modify: server/src/routes/files.ts
  • Modify: server/src/api.ts
  • Modify: server/src/worker.ts
  • Step 1: Add bridge token auth + new fields to upload route
In server/src/routes/files.ts, add this import at the top:
import { verifyBridgeToken } from '../lib/nextauth';
import { publishEvent } from '../lib/event-bus';
import { apiKeys } from '../schema';
In the POST /upload handler, find the line:
const user = c.get('user');
Replace that block with bridge-aware auth:
// Support bridge_upload scope tokens in addition to session users
let user = c.get('user');
let bridgeClinicId: string | null = null;

const authHeader = c.req.header('Authorization');
if (authHeader?.startsWith('Bearer ')) {
  const token = authHeader.slice(7);
  const bridgePayload = await verifyBridgeToken(token);
  if (bridgePayload) {
    // Verify keyId not revoked
    const db2 = await getDatabase(getDatabaseUrl());
    const [key] = await db2.select()
      .from(apiKeys)
      .where(eq(apiKeys.keyId, bridgePayload.keyId))
      .limit(1);
    if (!key || key.revokedAt) {
      return c.json({ error: 'Bridge API key revoked or invalid' }, 401);
    }
    bridgeClinicId = bridgePayload.sub;
    // Synthesise a minimal user context for the rest of the handler
    if (!user) {
      user = { id: 'bridge', role: 'bridge', clinicId: bridgeClinicId } as any;
      c.set('user', user);
    }
  }
}
In the same handler, find:
const clinicId = clinicContext?.currentClinicId || user.clinicId || '';
Replace with:
const clinicId = bridgeClinicId || clinicContext?.currentClinicId || user.clinicId || '';
After const formData = await c.req.formData();, add extraction of new fields:
const source = (formData.get('source') as string | null) || 'manual_upload';
const operatoryRoom = (formData.get('operatoryRoom') as string | null) || null;
Find the db.insert(patientFiles).values({...}) call (line ~246). Add the two new fields:
const [newFile] = await db.insert(patientFiles)
  .values({
    id: fileId,
    clinicId,
    patientId,
    fileName: file.name,
    fileType: fileType.toLowerCase() as 'x-ray' | 'document' | 'image' | 'report' | 'dicom',
    category,
    filePath: r2Key,
    fileSizeKb,
    toothNumber: toothNumber || null,
    uploadedBy: user.id !== 'bridge' ? user.id : null,
    notes: notes || null,
    source: source as 'manual_upload' | 'bridge_capture' | 'lab_dicom',
    operatoryRoom: operatoryRoom || null,
    uploadDate: new Date().toISOString().split('T')[0],
  })
  .returning();
After the insert (before the return c.json(...) line), add the event publish:
// Fire SSE event — non-blocking
if (fileType.toLowerCase() === 'x-ray' || fileType.toLowerCase() === 'dicom') {
  const [patientRow] = await db.select({ firstName: patients.firstName, lastName: patients.lastName })
    .from(patients)
    .where(eq(patients.id, patientId))
    .limit(1);
  const eventType = isDicom ? 'new_dicom' : 'new_xray';
  c.executionCtx?.waitUntil(
    publishEvent(c.env as any, clinicId, {
      type: eventType,
      patientId,
      patientName: patientRow ? `${patientRow.firstName} ${patientRow.lastName}` : '',
      fileId: newFile.id,
      toothNumber: toothNumber || null,
      source,
    })
  );
}
  • Step 2: Mount new routes in api.ts
In server/src/api.ts, add imports near the other route imports:
import clinicApiKeysRoute from './routes/clinic-api-keys';
import sseRoute from './routes/sse';
Find where protected routes are mounted (near protectedRoutes.route('/files', filesRoute)). Add:
protectedRoutes.route('/clinic/api-keys', clinicApiKeysRoute);
protectedRoutes.route('/sse', sseRoute);
  • Step 3: Export ClinicHub from worker.ts
In server/src/worker.ts, add:
export { ClinicHub } from './durable-objects/clinic-hub';
  • Step 4: Commit
git add server/src/routes/files.ts server/src/api.ts server/src/worker.ts
git commit -m "feat(upload): accept bridge tokens, fire SSE event after x-ray upload"

Task 5 — Bridge App: Project Setup

Files:
  • Create: bridge/package.json
  • Create: bridge/tsconfig.json
  • Create: bridge/electron-builder.config.js
  • Modify: pnpm-workspace.yaml
  • Step 1: Add bridge to pnpm workspace
Edit pnpm-workspace.yaml:
packages:
  - 'server'
  - 'database-server'
  - 'ui'
  - 'mobile'
  - 'shared'
  - 'bridge'
  • Step 2: Create bridge/package.json
{
  "name": "@odontox/bridge",
  "version": "1.0.0",
  "description": "OdontoX Bridge — operatory X-ray uploader",
  "main": "dist/main/index.js",
  "scripts": {
    "dev": "tsc -p tsconfig.json --watch",
    "build": "tsc -p tsconfig.json",
    "start": "electron dist/main/index.js",
    "pack": "npm run build && electron-builder --dir",
    "dist:win": "npm run build && electron-builder --win",
    "dist:mac": "npm run build && electron-builder --mac"
  },
  "dependencies": {
    "axios": "^1.7.9",
    "better-sqlite3": "^9.6.0",
    "chokidar": "^4.0.3",
    "electron-store": "^10.0.0",
    "electron-updater": "^6.3.9"
  },
  "devDependencies": {
    "@types/better-sqlite3": "^7.6.13",
    "@types/node": "^22.0.0",
    "electron": "^33.0.0",
    "electron-builder": "^25.1.8",
    "typescript": "^5.7.0"
  }
}
  • Step 3: Create bridge/tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "CommonJS",
    "moduleResolution": "node",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
  • Step 4: Create bridge/electron-builder.config.js
module.exports = {
  appId: 'io.odontox.bridge',
  productName: 'OdontoX Bridge',
  directories: { buildResources: 'assets', output: 'release' },
  files: ['dist/**/*', 'assets/**/*', 'src/renderer/**/*'],
  win: {
    target: 'nsis',
    icon: 'assets/icon.png',
    requestedExecutionLevel: 'asInvoker',
  },
  mac: {
    target: 'dmg',
    icon: 'assets/icon.png',
    category: 'public.app-category.medical',
  },
  nsis: {
    oneClick: true,
    runAfterFinish: true,
    createDesktopShortcut: false,
  },
  publish: [{ provider: 'generic', url: 'https://updates.odontox.io/bridge' }],
};
  • Step 5: Install dependencies
cd bridge && pnpm install
Expected: node_modules/ populated, including electron, better-sqlite3, chokidar.
  • Step 6: Create asset placeholders
mkdir -p bridge/assets bridge/src/main bridge/src/renderer/patient-selector
# Create 1x1 px placeholder PNGs — replace with real icons before distribution
node -e "
const { createCanvas } = require('canvas');
// If canvas not available, just touch the files
" 2>/dev/null || touch bridge/assets/tray-green.png bridge/assets/tray-yellow.png bridge/assets/tray-red.png bridge/assets/icon.png
Note: Replace placeholder PNGs with real 16×16 (tray) and 512×512 (icon) assets before building for distribution.
  • Step 7: Commit
git add bridge/ pnpm-workspace.yaml
git commit -m "feat(bridge): scaffold Electron bridge app project"

Task 6 — Bridge: Store Module

Files:
  • Create: bridge/src/main/store.ts
  • Step 1: Create typed electron-store config
// bridge/src/main/store.ts
import Store from 'electron-store';

export interface BridgeConfig {
  apiKey: string;
  serverUrl: string;
  watchFolder: string;
  roomId: string;
  selectedPatientId: string | null;
  selectedPatientName: string | null;
  selectedPatientAt: number | null; // unix ms — auto-clear after 4h
  autoStart: boolean;
  patientTimeoutMs: number; // default 4 * 60 * 60 * 1000
}

const defaults: BridgeConfig = {
  apiKey: '',
  serverUrl: 'https://api.odontox.io',
  watchFolder: '',
  roomId: '',
  selectedPatientId: null,
  selectedPatientName: null,
  selectedPatientAt: null,
  autoStart: true,
  patientTimeoutMs: 4 * 60 * 60 * 1000,
};

export const store = new Store<BridgeConfig>({ defaults });

export function getConfig(): BridgeConfig {
  return store.store;
}

export function setConfig<K extends keyof BridgeConfig>(key: K, value: BridgeConfig[K]) {
  store.set(key, value);
}

export function selectPatient(id: string, name: string) {
  store.set('selectedPatientId', id);
  store.set('selectedPatientName', name);
  store.set('selectedPatientAt', Date.now());
}

export function clearPatient() {
  store.set('selectedPatientId', null);
  store.set('selectedPatientName', null);
  store.set('selectedPatientAt', null);
}

export function isPatientExpired(): boolean {
  const { selectedPatientAt, patientTimeoutMs } = store.store;
  if (!selectedPatientAt) return true;
  return Date.now() - selectedPatientAt > patientTimeoutMs;
}
  • Step 2: Commit
git add bridge/src/main/store.ts
git commit -m "feat(bridge): electron-store config module"

Task 7 — Bridge: SQLite Queue Module

Files:
  • Create: bridge/src/main/queue.ts
  • Step 1: Create queue with proper states
// bridge/src/main/queue.ts
import Database from 'better-sqlite3';
import path from 'path';
import { app } from 'electron';

export type QueueStatus = 'pending' | 'done' | 'failed';

export interface QueueItem {
  id: number;
  filePath: string;
  fileHash: string;
  patientId: string;
  patientName: string;
  roomId: string | null;
  status: QueueStatus;
  attempts: number;
  lastError: string | null;
  createdAt: number;
}

let db: Database.Database;

export function initQueue() {
  const dbPath = path.join(app.getPath('userData'), 'queue.sqlite');
  db = new Database(dbPath);
  db.exec(`
    CREATE TABLE IF NOT EXISTS queue (
      id          INTEGER PRIMARY KEY AUTOINCREMENT,
      filePath    TEXT    NOT NULL,
      fileHash    TEXT    NOT NULL,
      patientId   TEXT    NOT NULL,
      patientName TEXT    NOT NULL DEFAULT '',
      roomId      TEXT,
      status      TEXT    NOT NULL DEFAULT 'pending',
      attempts    INTEGER NOT NULL DEFAULT 0,
      lastError   TEXT,
      createdAt   INTEGER NOT NULL DEFAULT (unixepoch())
    );
    CREATE UNIQUE INDEX IF NOT EXISTS queue_hash_patient
      ON queue (fileHash, patientId)
      WHERE status != 'done';
  `);
}

export function enqueue(filePath: string, fileHash: string, patientId: string, patientName: string, roomId: string | null) {
  // Ignore if already queued/done for this hash+patient combo
  const existing = db.prepare(
    `SELECT id FROM queue WHERE fileHash = ? AND patientId = ? AND status != 'done'`
  ).get(fileHash, patientId);
  if (existing) return;

  db.prepare(`
    INSERT INTO queue (filePath, fileHash, patientId, patientName, roomId)
    VALUES (?, ?, ?, ?, ?)
  `).run(filePath, fileHash, patientId, patientName, roomId ?? null);
}

export function getPending(limit = 10): QueueItem[] {
  return db.prepare(
    `SELECT * FROM queue WHERE status = 'pending' AND attempts < 5 ORDER BY id LIMIT ?`
  ).all(limit) as QueueItem[];
}

export function markDone(id: number) {
  db.prepare(`UPDATE queue SET status = 'done' WHERE id = ?`).run(id);
}

export function markFailed(id: number, error: string) {
  db.prepare(`
    UPDATE queue SET attempts = attempts + 1, lastError = ? WHERE id = ?
  `).run(error, id);
  // After 5 attempts → terminal failed
  db.prepare(`
    UPDATE queue SET status = 'failed' WHERE id = ? AND attempts >= 5
  `).run(id);
}

export function getFailedCount(): number {
  return (db.prepare(`SELECT COUNT(*) as n FROM queue WHERE status = 'failed'`).get() as { n: number }).n;
}

export function getPendingCount(): number {
  return (db.prepare(`SELECT COUNT(*) as n FROM queue WHERE status = 'pending'`).get() as { n: number }).n;
}

export function retryFailed() {
  db.prepare(`UPDATE queue SET status = 'pending', attempts = 0, lastError = NULL WHERE status = 'failed'`).run();
}

export function getRecentUploads(limit = 20): QueueItem[] {
  return db.prepare(
    `SELECT * FROM queue ORDER BY id DESC LIMIT ?`
  ).all(limit) as QueueItem[];
}
  • Step 2: Commit
git add bridge/src/main/queue.ts
git commit -m "feat(bridge): SQLite queue with pending/done/failed states and hash dedup"

Task 8 — Bridge: File Watcher Module

Files:
  • Create: bridge/src/main/watcher.ts
  • Step 1: Create chokidar watcher
// bridge/src/main/watcher.ts
import chokidar, { FSWatcher } from 'chokidar';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import { enqueue } from './queue';
import { getConfig, isPatientExpired } from './store';

const ALLOWED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif']);
const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50MB guard

let watcher: FSWatcher | null = null;
let onNewFile: (() => void) | null = null;
let onNoPatient: (() => void) | null = null;

function sha256(filePath: string): string {
  const buf = fs.readFileSync(filePath);
  return crypto.createHash('sha256').update(buf).digest('hex');
}

export function startWatcher(callbacks: {
  onNewFile: () => void;
  onNoPatient: () => void;
}) {
  onNewFile = callbacks.onNewFile;
  onNoPatient = callbacks.onNoPatient;

  const { watchFolder } = getConfig();
  if (!watchFolder) return;

  watcher = chokidar.watch(watchFolder, {
    awaitWriteFinish: { stabilityThreshold: 800, pollInterval: 100 },
    ignored: /(^|[/\\])\../,
    persistent: true,
    ignoreInitial: true,
  });

  watcher.on('add', (filePath: string) => {
    const ext = path.extname(filePath).toLowerCase();
    if (!ALLOWED_EXTENSIONS.has(ext)) return;

    const stat = fs.statSync(filePath);
    if (stat.size > MAX_FILE_SIZE_BYTES) return;

    const config = getConfig();

    if (!config.selectedPatientId || isPatientExpired()) {
      onNoPatient?.();
      return;
    }

    const hash = sha256(filePath);
    enqueue(filePath, hash, config.selectedPatientId, config.selectedPatientName || '', config.roomId || null);
    onNewFile?.();
  });
}

export function stopWatcher() {
  watcher?.close();
  watcher = null;
}

export function restartWatcher(callbacks: { onNewFile: () => void; onNoPatient: () => void }) {
  stopWatcher();
  startWatcher(callbacks);
}
  • Step 2: Commit
git add bridge/src/main/watcher.ts
git commit -m "feat(bridge): chokidar file watcher with size guard and hash dedup"

Task 9 — Bridge: Uploader + API Client

Files:
  • Create: bridge/src/main/uploader.ts
  • Step 1: Create uploader with retry and tray updates
// bridge/src/main/uploader.ts
import axios from 'axios';
import fs from 'fs';
import FormData from 'form-data';
import { getPending, markDone, markFailed, getPendingCount, getFailedCount } from './queue';
import { getConfig } from './store';

export type TrayState = 'green' | 'yellow' | 'red';
let onTrayUpdate: ((state: TrayState, tooltip: string) => void) | null = null;
let flushing = false;

export function setTrayCallback(cb: (state: TrayState, tooltip: string) => void) {
  onTrayUpdate = cb;
}

export function updateTray() {
  const pending = getPendingCount();
  const failed = getFailedCount();
  const { apiKey, watchFolder } = getConfig();

  if (!apiKey || !watchFolder) {
    onTrayUpdate?.('red', 'OdontoX Bridge — not configured');
    return;
  }
  if (failed > 0) {
    onTrayUpdate?.('red', `OdontoX Bridge — ${failed} upload(s) failed`);
  } else if (pending > 0) {
    onTrayUpdate?.('yellow', `OdontoX Bridge — ${pending} queued`);
  } else {
    onTrayUpdate?.('green', 'OdontoX Bridge — connected');
  }
}

export async function flushQueue() {
  if (flushing) return;
  flushing = true;

  try {
    const items = getPending(10);

    for (const item of items) {
      try {
        if (!fs.existsSync(item.filePath)) {
          markFailed(item.id, 'File no longer exists');
          continue;
        }

        const { apiKey, serverUrl, roomId } = getConfig();
        const form = new FormData();
        form.append('file', fs.createReadStream(item.filePath));
        form.append('patientId', item.patientId);
        form.append('fileType', 'x-ray');
        form.append('category', 'operatory_capture');
        form.append('source', 'bridge_capture');
        if (item.roomId) form.append('operatoryRoom', item.roomId);

        await axios.post(`${serverUrl}/api/v1/protected/files/upload`, form, {
          headers: {
            Authorization: `Bearer ${apiKey}`,
            ...form.getHeaders(),
          },
          timeout: 60_000,
        });

        markDone(item.id);
      } catch (err: any) {
        const msg = err?.response?.data?.error || err?.message || 'Unknown error';
        markFailed(item.id, msg);
      }
    }
  } finally {
    flushing = false;
    updateTray();
  }
}

// Retry every 30 seconds
export function startFlushInterval() {
  flushQueue();
  setInterval(flushQueue, 30_000);
}

export async function searchPatients(query: string): Promise<Array<{ id: string; firstName: string; lastName: string; patientNumber: string; lastVisit?: string }>> {
  const { apiKey, serverUrl } = getConfig();
  if (!apiKey) return [];
  try {
    const res = await axios.get(`${serverUrl}/api/v1/protected/patients`, {
      params: { search: query, limit: 10 },
      headers: { Authorization: `Bearer ${apiKey}` },
      timeout: 10_000,
    });
    return res.data?.patients || res.data || [];
  } catch {
    return [];
  }
}
  • Step 2: Commit
git add bridge/src/main/uploader.ts
git commit -m "feat(bridge): upload flush with retry, tray state callback, patient search"

Task 10 — Bridge: Tray + Main Process

Files:
  • Create: bridge/src/main/tray.ts
  • Create: bridge/src/main/index.ts
  • Step 1: Create tray module
// bridge/src/main/tray.ts
import { Tray, Menu, nativeImage, BrowserWindow, shell, app, Notification } from 'electron';
import path from 'path';
import { getConfig, selectPatient, clearPatient } from './store';
import { flushQueue, updateTray } from './uploader';
import { getRecentUploads, retryFailed } from './queue';

let tray: Tray | null = null;
let patientWindow: BrowserWindow | null = null;

function icon(name: string) {
  return nativeImage.createFromPath(path.join(__dirname, '../../assets', name));
}

export function createTray() {
  tray = new Tray(icon('tray-green.png'));
  tray.setToolTip('OdontoX Bridge');
  rebuildMenu();
  return tray;
}

export function setTrayState(state: 'green' | 'yellow' | 'red', tooltip: string) {
  if (!tray) return;
  const iconName = `tray-${state}.png`;
  tray.setImage(icon(iconName));
  tray.setToolTip(tooltip);
  rebuildMenu();
}

export function rebuildMenu() {
  if (!tray) return;
  const cfg = getConfig();
  const patientLabel = cfg.selectedPatientId
    ? `Patient: ${cfg.selectedPatientName} ✓`
    : 'Select Patient…';

  const menu = Menu.buildFromTemplate([
    {
      label: patientLabel,
      click: () => openPatientSelector(),
    },
    { label: 'Clear Patient', click: () => { clearPatient(); rebuildMenu(); }, enabled: !!cfg.selectedPatientId },
    { type: 'separator' },
    { label: 'Retry Failed Uploads', click: () => { retryFailed(); flushQueue(); } },
    {
      label: 'View Upload Log',
      click: () => {
        const items = getRecentUploads(20);
        const lines = items.map(i => `[${i.status}] ${path.basename(i.filePath)}${i.patientName}`).join('\n');
        new Notification({ title: 'Recent Uploads', body: lines.slice(0, 200) || 'No uploads yet' }).show();
      },
    },
    { type: 'separator' },
    { label: 'Open OdontoX', click: () => shell.openExternal(cfg.serverUrl.replace('api.', 'portal.')) },
    { type: 'separator' },
    { label: 'Quit', click: () => app.quit() },
  ]);

  tray.setContextMenu(menu);
}

export function openPatientSelector() {
  if (patientWindow && !patientWindow.isDestroyed()) {
    patientWindow.focus();
    return;
  }

  patientWindow = new BrowserWindow({
    width: 420,
    height: 340,
    resizable: false,
    alwaysOnTop: true,
    title: 'Select Patient',
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: true,
      preload: path.join(__dirname, 'preload.js'),
    },
  });

  patientWindow.loadFile(path.join(__dirname, '../../src/renderer/patient-selector/index.html'));
  patientWindow.on('closed', () => { patientWindow = null; });
}

export function showNoPatientNotification() {
  new Notification({
    title: 'OdontoX Bridge',
    body: 'No patient selected — image was not queued. Select a patient first.',
  }).show();
}
  • Step 2: Create main entry point
// bridge/src/main/index.ts
import { app, ipcMain } from 'electron';
import { createTray, setTrayState, rebuildMenu, showNoPatientNotification } from './tray';
import { initQueue } from './queue';
import { startWatcher } from './watcher';
import { startFlushInterval, setTrayCallback, flushQueue } from './uploader';
import { selectPatient, isPatientExpired, getConfig } from './store';
import { searchPatients } from './uploader';
import { autoUpdater } from 'electron-updater';

app.setName('OdontoX Bridge');

// Hide from macOS Dock — tray-only app
if (process.platform === 'darwin') app.dock?.hide();

app.whenReady().then(() => {
  initQueue();

  const tray = createTray();

  setTrayCallback((state, tooltip) => {
    setTrayState(state, tooltip);
  });

  startWatcher({
    onNewFile: () => flushQueue(),
    onNoPatient: () => showNoPatientNotification(),
  });

  startFlushInterval();

  // Auto-clear patient after configured timeout
  setInterval(() => {
    if (isPatientExpired()) {
      const { selectedPatientId } = getConfig();
      if (selectedPatientId) {
        const { clearPatient } = require('./store');
        clearPatient();
        rebuildMenu();
      }
    }
  }, 60_000);

  // Auto-updater
  autoUpdater.checkForUpdatesAndNotify().catch(() => {});
});

app.on('window-all-closed', (e: Event) => {
  e.preventDefault(); // Keep tray alive
});

// IPC: Patient selector renderer calls
ipcMain.handle('search-patients', async (_event, query: string) => {
  return searchPatients(query);
});

ipcMain.handle('select-patient', (_event, id: string, name: string) => {
  selectPatient(id, name);
  rebuildMenu();
  return true;
});

ipcMain.handle('get-selected-patient', () => {
  const cfg = getConfig();
  return { id: cfg.selectedPatientId, name: cfg.selectedPatientName };
});
  • Step 3: Create preload.ts
// bridge/src/main/preload.ts
import { contextBridge, ipcRenderer } from 'electron';

contextBridge.exposeInMainWorld('bridge', {
  searchPatients: (q: string) => ipcRenderer.invoke('search-patients', q),
  selectPatient: (id: string, name: string) => ipcRenderer.invoke('select-patient', id, name),
  getSelectedPatient: () => ipcRenderer.invoke('get-selected-patient'),
});
  • Step 4: Commit
git add bridge/src/main/tray.ts bridge/src/main/index.ts bridge/src/main/preload.ts
git commit -m "feat(bridge): tray icon, IPC preload, and main process entry"

Task 11 — Bridge: Patient Selector Renderer

Files:
  • Create: bridge/src/renderer/patient-selector/index.html
  • Step 1: Create patient selector window
<!-- bridge/src/renderer/patient-selector/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'unsafe-inline'" />
  <title>Select Patient</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #fff; padding: 16px; }
    h2 { font-size: 14px; font-weight: 600; color: #1a1a2e; margin-bottom: 12px; }
    input {
      width: 100%; padding: 8px 12px; font-size: 14px; border: 1px solid #d1d5db;
      border-radius: 8px; outline: none;
    }
    input:focus { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,.15); }
    #results { margin-top: 8px; max-height: 220px; overflow-y: auto; }
    .result {
      padding: 10px 12px; border-radius: 6px; cursor: pointer;
      display: flex; justify-content: space-between; align-items: center;
    }
    .result:hover { background: #f3f4f6; }
    .result .name { font-size: 13px; font-weight: 500; color: #111827; }
    .result .meta { font-size: 11px; color: #6b7280; }
    .empty { padding: 16px; text-align: center; color: #9ca3af; font-size: 13px; }
    .selected { background: #eff6ff; border: 1px solid #bfdbfe; padding: 8px 12px;
      border-radius: 8px; margin-top: 8px; font-size: 12px; color: #1d4ed8; }
  </style>
</head>
<body>
  <h2>Select Patient</h2>
  <input id="search" type="text" placeholder="Search by name or patient number…" autofocus />
  <div id="results"></div>
  <div id="selected-info"></div>

  <script>
    const bridge = window.bridge;
    const searchEl = document.getElementById('search');
    const resultsEl = document.getElementById('results');
    const selectedInfoEl = document.getElementById('selected-info');
    let debounce = null;

    bridge.getSelectedPatient().then(p => {
      if (p?.name) {
        selectedInfoEl.innerHTML = `<div class="selected">Currently selected: <strong>${p.name}</strong></div>`;
      }
    });

    searchEl.addEventListener('input', () => {
      clearTimeout(debounce);
      const q = searchEl.value.trim();
      if (q.length < 2) { resultsEl.innerHTML = ''; return; }
      debounce = setTimeout(async () => {
        const patients = await bridge.searchPatients(q);
        if (!patients.length) {
          resultsEl.innerHTML = '<div class="empty">No patients found</div>';
          return;
        }
        resultsEl.innerHTML = patients.map(p => `
          <div class="result" data-id="${p.id}" data-name="${p.firstName} ${p.lastName}">
            <div>
              <div class="name">${p.firstName} ${p.lastName}</div>
              <div class="meta">${p.patientNumber || ''}${p.lastVisit ? ' · Last: ' + p.lastVisit : ''}</div>
            </div>
            <span style="font-size:18px">→</span>
          </div>
        `).join('');
      }, 300);
    });

    resultsEl.addEventListener('click', async (e) => {
      const row = e.target.closest('.result');
      if (!row) return;
      const { id, name } = row.dataset;
      await bridge.selectPatient(id, name);
      window.close();
    });
  </script>
</body>
</html>
  • Step 2: Build and test
cd bridge && pnpm build && pnpm start
Expected: Tray icon appears in menu bar (Mac) or system tray (Windows). Right-click shows menu with “Select Patient…”. Clicking it opens the search window. Typing a name shows results. Clicking a result closes the window and updates the tray tooltip.
  • Step 3: Commit
git add bridge/src/renderer/
git commit -m "feat(bridge): patient selector renderer window"

Task 12 — Frontend: Event Bus Context + SSE Hook

Files:
  • Create: ui/src/contexts/ClinicEventsContext.tsx
  • Create: ui/src/hooks/useClinicEvents.ts
  • Modify: ui/src/App.tsx
  • Step 1: Create event bus context
// ui/src/contexts/ClinicEventsContext.tsx
import { createContext, useContext, useRef, useCallback, ReactNode } from 'react';

export interface ClinicEvent {
  type: 'new_xray' | 'new_dicom' | 'ping';
  patientId?: string;
  patientName?: string;
  fileId?: string;
  toothNumber?: string | null;
  source?: string;
  labCaseId?: string | null;
  ts?: number;
}

type Listener = (event: ClinicEvent) => void;

interface ClinicEventsBus {
  on: (type: ClinicEvent['type'], fn: Listener) => () => void;
  emit: (event: ClinicEvent) => void;
}

const ClinicEventsContext = createContext<ClinicEventsBus | null>(null);

export function ClinicEventsProvider({ children }: { children: ReactNode }) {
  const listenersRef = useRef<Map<string, Set<Listener>>>(new Map());

  const on = useCallback((type: ClinicEvent['type'], fn: Listener) => {
    const map = listenersRef.current;
    if (!map.has(type)) map.set(type, new Set());
    map.get(type)!.add(fn);
    return () => map.get(type)?.delete(fn);
  }, []);

  const emit = useCallback((event: ClinicEvent) => {
    listenersRef.current.get(event.type)?.forEach(fn => fn(event));
  }, []);

  return (
    <ClinicEventsContext.Provider value={{ on, emit }}>
      {children}
    </ClinicEventsContext.Provider>
  );
}

export function useEventBus(): ClinicEventsBus {
  const ctx = useContext(ClinicEventsContext);
  if (!ctx) throw new Error('useEventBus must be used inside ClinicEventsProvider');
  return ctx;
}
  • Step 2: Create SSE hook
// ui/src/hooks/useClinicEvents.ts
import { useEffect } from 'react';
import { useEventBus, type ClinicEvent } from '@/contexts/ClinicEventsContext';
import { useAuth } from '@/lib/auth-context';

export function useClinicEvents() {
  const bus = useEventBus();
  const { user } = useAuth();

  useEffect(() => {
    if (!user) return;

    const es = new EventSource('/api/v1/protected/sse/clinic-events', { withCredentials: true });

    es.onmessage = (e) => {
      try {
        const event = JSON.parse(e.data) as ClinicEvent;
        if (event.type !== 'ping') bus.emit(event);
      } catch {}
    };

    es.onerror = () => {
      // Browser auto-reconnects EventSource — no manual retry needed
    };

    return () => es.close();
  }, [user, bus]);
}
  • Step 3: Mount in App.tsx
In ui/src/App.tsx, find the imports and add:
import { ClinicEventsProvider } from '@/contexts/ClinicEventsContext';
Find the main return/render in App.tsx where providers are nested. Wrap the existing provider tree with <ClinicEventsProvider>:
// Wrap the outermost existing provider:
<ClinicEventsProvider>
  {/* existing providers remain unchanged */}
  <ThemeProvider>
    ...
  </ThemeProvider>
</ClinicEventsProvider>
Then create a ClinicEventsMount component and render it inside the authenticated route scope:
// Add near top of App.tsx
import { useClinicEvents } from '@/hooks/useClinicEvents';

function ClinicEventsMount() {
  useClinicEvents();
  return null;
}
Place <ClinicEventsMount /> inside the authenticated section of the router (after the auth check, before <AppLayout>).
  • Step 4: Commit
git add ui/src/contexts/ClinicEventsContext.tsx ui/src/hooks/useClinicEvents.ts ui/src/App.tsx
git commit -m "feat(frontend): ClinicEvents context + SSE hook mounted at app root"

Task 13 — Frontend: Live X-ray Toast

Files:
  • Create: ui/src/components/notifications/XrayToast.tsx
  • Modify: ui/src/App.tsx
  • Step 1: Create XrayToast component
// ui/src/components/notifications/XrayToast.tsx
import { useEffect } from 'react';
import { toast } from 'sonner';
import { useEventBus } from '@/contexts/ClinicEventsContext';
import { useNavigate } from 'react-router-dom';

export function XrayToast() {
  const bus = useEventBus();
  const navigate = useNavigate();

  useEffect(() => {
    return bus.on('new_xray', (event) => {
      toast(
        `New X-ray — ${event.patientName}${event.toothNumber ? ` · Tooth ${event.toothNumber}` : ''}`,
        {
          duration: 12_000,
          action: {
            label: 'View',
            onClick: () => navigate(`/patients/${event.patientId}/files/${event.fileId}`),
          },
        }
      );
    });
  }, [bus, navigate]);

  return null;
}
  • Step 2: Mount in App.tsx
In ui/src/App.tsx, add import:
import { XrayToast } from '@/components/notifications/XrayToast';
Place <XrayToast /> right below <ClinicEventsMount /> inside the authenticated scope. The <Toaster /> component is already rendered in App.tsx (it uses sonner) — no changes needed there.
  • Step 3: Commit
git add ui/src/components/notifications/XrayToast.tsx ui/src/App.tsx
git commit -m "feat(frontend): live X-ray toast notification via SSE"

Task 14 — Frontend: Dental Chart Tooth X-ray Badge

Files:
  • Modify: ui/src/components/dental/OdontogramChart.tsx
  • Modify: ui/src/lib/odontogram-api.ts
  • Step 1: Add API function for per-patient X-ray tooth set
In ui/src/lib/odontogram-api.ts, append:
export async function getTeethWithXrays(patientId: string): Promise<Set<string>> {
  const response = await fetchWithAuth(
    `/api/v1/protected/patient-files?patientId=${patientId}&fileType=x-ray`
  );
  if (!response.ok) return new Set();
  const data = await response.json();
  const files: Array<{ toothNumber?: string | null }> = data.files || data || [];
  const teeth = new Set<string>();
  for (const f of files) {
    if (f.toothNumber) teeth.add(f.toothNumber);
  }
  return teeth;
}
  • Step 2: Add xray badge state and SSE subscription to OdontogramChart
In ui/src/components/dental/OdontogramChart.tsx, add imports near the top:
import { getTeethWithXrays } from '@/lib/odontogram-api';
import { useEventBus } from '@/contexts/ClinicEventsContext';
Inside the OdontogramChart component, add state and load logic. Find const [toothFiles, setToothFiles] = useState<any[]>([]); (line ~134) and add below it:
const [teethWithXrays, setTeethWithXrays] = useState<Set<string>>(new Set());
const bus = useEventBus();
Find the loadChart or initial data load useEffect and add:
if (selectedPatientId) {
  getTeethWithXrays(selectedPatientId).then(set => setTeethWithXrays(set));
}
Add an SSE subscription inside the same or a new useEffect:
useEffect(() => {
  return bus.on('new_xray', (event) => {
    if (event.patientId === selectedPatientId && event.toothNumber) {
      setTeethWithXrays(prev => new Set([...prev, event.toothNumber!]));
    }
  });
}, [bus, selectedPatientId]);
  • Step 3: Render badge dot on teeth with X-rays
In OdontogramChart, find where tooth SVG elements are rendered (search for onClick={() => and the tooth number rendering, around line ~480–530). In the JSX that renders each tooth, add a badge indicator when the tooth number is in teethWithXrays:
{/* Inside the tooth render, after the existing tooth SVG content: */}
{teethWithXrays.has(tooth.number) && (
  <div
    className="absolute top-0 right-0 w-2 h-2 rounded-full bg-teal-500"
    title={`X-ray on file`}
    style={{ transform: 'translate(30%, -30%)' }}
  />
)}
The exact insertion point depends on the tooth element structure. The tooth element should be relative positioned (add relative to its className if not already there).
  • Step 4: Commit
git add ui/src/components/dental/OdontogramChart.tsx ui/src/lib/odontogram-api.ts
git commit -m "feat(dental-chart): teal badge dot on teeth with X-ray files, updates live via SSE"

Task 15 — Frontend: Appointment Live X-ray Panel

Files:
  • Modify: ui/src/components/appointments/AppointmentDetailPage.tsx
  • Step 1: Add live X-ray panel for in_progress appointments
In ui/src/components/appointments/AppointmentDetailPage.tsx, add imports:
import { useState, useEffect } from 'react'; // already imported, verify
import { useEventBus } from '@/contexts/ClinicEventsContext';
Inside the component, add state:
const bus = useEventBus();
const [liveXrays, setLiveXrays] = useState<Array<{ fileId: string; patientName: string; toothNumber?: string | null }>>([]);
Add SSE subscription effect (add after existing useEffects):
useEffect(() => {
  if (appointment?.status !== 'in_progress') return;
  return bus.on('new_xray', (event) => {
    if (event.patientId !== appointment.patientId) return;
    setLiveXrays(prev => [
      ...prev,
      { fileId: event.fileId!, patientName: event.patientName || '', toothNumber: event.toothNumber },
    ]);
  });
}, [bus, appointment?.status, appointment?.patientId]);
In the JSX, find the bottom of the appointment detail content (after the invoice card section). Add the live panel:
{appointment?.status === 'in_progress' && (
  <div className="mt-4 p-4 rounded-xl border border-teal-200 bg-teal-50 dark:bg-teal-950/20 dark:border-teal-800">
    <div className="flex items-center gap-2 mb-3">
      <span className="h-2 w-2 rounded-full bg-teal-500 animate-pulse" />
      <span className="text-sm font-medium text-teal-800 dark:text-teal-300">
        Live X-rays{liveXrays.length > 0 ? ` (${liveXrays.length})` : ''}
      </span>
    </div>
    {liveXrays.length === 0 ? (
      <p className="text-xs text-teal-600 dark:text-teal-400">Waiting for X-rays from operatory…</p>
    ) : (
      <div className="flex flex-wrap gap-2">
        {liveXrays.map((xray, i) => (
          <button
            key={xray.fileId}
            className="px-3 py-1.5 rounded-lg text-xs bg-white dark:bg-zinc-900 border border-teal-200 dark:border-teal-700 text-teal-700 dark:text-teal-300 hover:bg-teal-100 transition"
            onClick={() => window.open(`/api/v1/protected/files/${xray.fileId}/download`, '_blank')}
          >
            {xray.toothNumber ? `Tooth ${xray.toothNumber}` : `X-ray ${i + 1}`}
          </button>
        ))}
      </div>
    )}
  </div>
)}
  • Step 2: Commit
git add ui/src/components/appointments/AppointmentDetailPage.tsx
git commit -m "feat(appointment): live X-ray panel for in_progress appointments via SSE"

Task 16 — Frontend: Clinical Notes X-ray Attachment

Files:
  • Modify: ui/src/components/dental/OdontogramChart.tsx (clinical notes section within chart)
  • Or if notes are edited elsewhere: find the component for note editing via grep -r "chiefComplaint\|clinicalNote" ui/src --include="*.tsx" -l
  • Step 1: Find the clinical notes editing component
grep -rl "chiefComplaint\|visitDate\|clinicalNote\|ClinicalNote" /Users/ssh/Documents/Beta-App/odontoX/ui/src --include="*.tsx" | head -10
Note the component path from the result — the next steps modify that file.
  • Step 2: Add recent X-rays query to the note form
In the clinical note form/editing component, add:
const [recentXrays, setRecentXrays] = useState<Array<{ id: string; fileName: string; toothNumber?: string | null; createdAt: string }>>([]);
const [attachedFileIds, setAttachedFileIds] = useState<string[]>([]);

// Load X-rays uploaded in the last 60 minutes for this patient
useEffect(() => {
  if (!patientId) return;
  const since = new Date(Date.now() - 60 * 60 * 1000).toISOString();
  fetchWithAuth(`/api/v1/protected/patient-files?patientId=${patientId}&fileType=x-ray&since=${since}`)
    .then(r => r.json())
    .then(data => setRecentXrays(data.files || data || []))
    .catch(() => {});
}, [patientId]);
  • Step 3: Render X-ray attachment checkboxes
In the form JSX, add a section before the submit button:
{recentXrays.length > 0 && (
  <div className="mt-4">
    <p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Attach recent X-rays</p>
    <div className="space-y-1.5">
      {recentXrays.map(xray => (
        <label key={xray.id} className="flex items-center gap-2 cursor-pointer">
          <input
            type="checkbox"
            className="rounded border-gray-300"
            checked={attachedFileIds.includes(xray.id)}
            onChange={(e) => {
              setAttachedFileIds(prev =>
                e.target.checked ? [...prev, xray.id] : prev.filter(id => id !== xray.id)
              );
            }}
          />
          <span className="text-sm text-gray-600 dark:text-gray-400">
            {xray.fileName}{xray.toothNumber ? ` (Tooth ${xray.toothNumber})` : ''}
          </span>
        </label>
      ))}
    </div>
  </div>
)}
  • Step 4: Include attachedFileIds in the save payload
When the note form submits, include attachedFileIds in the request body. Find the save/submit handler and add attachedFileIds to the payload object. Also update the patient-files route to support since query param — in server/src/routes/patient-files.ts, find the GET handler and add:
const since = c.req.query('since');
// Add to the WHERE clause:
...(since ? [gte(patientFiles.createdAt, new Date(since))] : [])
And update clinical notes create/update routes in server/src/routes/clinical-notes.ts to accept and store attachedFileIds.
  • Step 5: Commit
git add ui/src/ server/src/routes/patient-files.ts server/src/routes/clinical-notes.ts
git commit -m "feat(clinical-notes): attach recent X-rays when writing notes"

Task 17 — Frontend: Lab DICOM ZIP Uploader

Files:
  • Modify: ui/src/components/doctor/LabCaseDetailView.tsx
  • Step 1: Add JSZip dependency
cd ui && pnpm add jszip && pnpm add -D @types/jszip
  • Step 2: Add Radiology Files section to LabCaseDetailView
In ui/src/components/doctor/LabCaseDetailView.tsx, add imports:
import JSZip from 'jszip';
import { fetchWithAuth } from '@/lib/serverComm';
import { useState, useRef } from 'react'; // already imported, verify
Add state:
const [dicomProgress, setDicomProgress] = useState<{ done: number; total: number } | null>(null);
const [dicomError, setDicomError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
Add the upload handler:
async function handleDicomZip(file: File) {
  if (file.size > 200 * 1024 * 1024) {
    setDicomError('ZIP file exceeds 200MB limit. Upload individual .dcm files or split into smaller ZIPs.');
    return;
  }

  let dcmFiles: Array<{ name: string; blob: Blob }> = [];

  if (file.name.endsWith('.zip')) {
    const zip = await JSZip.loadAsync(file);
    dcmFiles = await Promise.all(
      Object.values(zip.files)
        .filter(f => !f.dir && f.name.endsWith('.dcm'))
        .map(async f => ({ name: f.name, blob: await f.async('blob') }))
    );
  }

  if (!dcmFiles.length) {
    setDicomError('No .dcm files found in the ZIP.');
    return;
  }

  setDicomProgress({ done: 0, total: dcmFiles.length });
  setDicomError(null);
  let done = 0;

  for (const dcm of dcmFiles) {
    const formData = new FormData();
    formData.append('file', new File([dcm.blob], dcm.name, { type: 'application/dicom' }));
    formData.append('patientId', labCase.patientId || '');
    formData.append('fileType', 'dicom');
    formData.append('category', 'lab_scan');
    formData.append('source', 'lab_dicom');
    formData.append('notes', `labCaseId:${labCase.id}`);

    try {
      await fetchWithAuth('/api/v1/protected/files/upload', { method: 'POST', body: formData });
    } catch {}
    done++;
    setDicomProgress({ done, total: dcmFiles.length });
  }

  setDicomProgress(null);
}
In the JSX, find the bottom of the lab case detail (before or after the “Notes” section). Add the Radiology Files section:
<div className="mt-6">
  <h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Radiology Files (DICOM)</h3>

  <div
    className="border-2 border-dashed border-gray-200 dark:border-zinc-700 rounded-xl p-6 text-center cursor-pointer hover:border-teal-400 transition"
    onClick={() => fileInputRef.current?.click()}
    onDragOver={(e) => e.preventDefault()}
    onDrop={(e) => {
      e.preventDefault();
      const f = e.dataTransfer.files[0];
      if (f) handleDicomZip(f);
    }}
  >
    <p className="text-sm text-gray-500 dark:text-gray-400">
      Drop a DICOM <strong>.zip</strong> here or click to browse
    </p>
    <p className="text-xs text-gray-400 mt-1">Max 200MB · .dcm files inside ZIP</p>
  </div>

  <input
    ref={fileInputRef}
    type="file"
    accept=".zip"
    className="hidden"
    onChange={(e) => { const f = e.target.files?.[0]; if (f) handleDicomZip(f); }}
  />

  {dicomProgress && (
    <div className="mt-3">
      <div className="flex justify-between text-xs text-gray-500 mb-1">
        <span>Uploading DICOM files…</span>
        <span>{dicomProgress.done}/{dicomProgress.total}</span>
      </div>
      <div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
        <div
          className="h-full bg-teal-500 transition-all"
          style={{ width: `${(dicomProgress.done / dicomProgress.total) * 100}%` }}
        />
      </div>
    </div>
  )}

  {dicomError && (
    <p className="mt-2 text-xs text-red-500">{dicomError}</p>
  )}
</div>
  • Step 3: Commit
git add ui/src/components/doctor/LabCaseDetailView.tsx
git commit -m "feat(lab-cases): DICOM ZIP drag-drop uploader with progress bar"

Self-Review

Spec Coverage Check

Spec SectionTask
Workflow A bridge appTasks 5–11
Workflow B lab DICOMTask 17
Schema: source + operatoryRoomTask 1
Schema: attachedFileIdsTask 1
Bridge API key endpointTask 2
SSE endpointTask 3
SSE event after uploadTask 4
Bridge file watcherTask 8
Bridge offline queueTask 7
Bridge patient selectorTasks 10–11
Bridge retry/tray statesTasks 9–10
Frontend SSE hookTask 12
Live X-ray toastTask 13
Dental chart tooth badgeTask 14
Appointment live panelTask 15
Clinical notes attachmentTask 16
Lab DICOM uploadTask 17
API key revocationTask 2
Bridge electron-builderTask 5

Type Consistency

  • ClinicEvent type defined in server/src/durable-objects/clinic-hub.ts and mirrored in ui/src/contexts/ClinicEventsContext.tsx — keep in sync
  • QueueStatus = 'pending' | 'done' | 'failed' used consistently across queue.ts and uploader.ts
  • patientFileSourceEnum values 'manual_upload' | 'bridge_capture' | 'lab_dicom' match across schema, upload route, and bridge uploader
  • BridgeTokenPayload.sub = clinicId — used correctly in Task 4 bridge auth check

Known Gaps / Notes

  1. Icon assets: bridge/assets/*.png are empty placeholders — replace with real 16×16 tray icons and 512×512 app icon before first distribution build.
  2. Task 16 exact file path: Step 1 asks you to grep for the clinical notes form component — the exact path depends on where notes are edited in the UI (could be inside OdontogramChart.tsx or a separate modal).
  3. Task 3 CF Workers wrangler: After adding the [durable_objects] binding, run wrangler deploy once to register the migration — the DO class must be deployed before it can be used.
  4. Auto-updater: electron-updater expects a RELEASES file at https://updates.odontox.io/bridge/ — set up an R2 bucket at that path or use GitHub Releases as the update host.
  5. Bridge token in upload route: The bridge sends a Bearer token. The existing dualAuthMiddleware on protected routes also verifies the bearer token as a session JWT. Add the bridge check before the session middleware fires, or add a bypass in dualAuthMiddleware when type === 'bridge'.