OdontoX Bridge — X-ray Integration Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: UseGoal: Build a standalone Electron bridge app insuperpowers:subagent-driven-development(recommended) orsuperpowers:executing-plansto implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
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)
| File | Purpose |
|---|---|
bridge/package.json | Electron app dependencies and build scripts |
bridge/tsconfig.json | TypeScript config for main + renderer |
bridge/electron-builder.config.js | NSIS/DMG packaging config |
bridge/src/main/index.ts | Electron entry — app lifecycle, tray mount |
bridge/src/main/store.ts | electron-store typed config (apiKey, serverUrl, watchFolder, roomId, selectedPatient) |
bridge/src/main/queue.ts | SQLite queue with pending/done/failed states |
bridge/src/main/watcher.ts | chokidar folder watcher → enqueue |
bridge/src/main/uploader.ts | flush queue → POST /files/upload, retry, hash dedup |
bridge/src/main/tray.ts | Tray icon state machine (green/yellow/red), menu |
bridge/src/main/preload.ts | contextBridge IPC for patient selector renderer |
bridge/src/renderer/patient-selector/index.html | Patient search window HTML |
bridge/src/renderer/patient-selector/app.ts | Renderer JS: search input → IPC → select |
bridge/assets/tray-green.png | 16×16 tray icon (connected) |
bridge/assets/tray-yellow.png | 16×16 tray icon (offline/queued) |
bridge/assets/tray-red.png | 16×16 tray icon (error) |
bridge/assets/icon.png | 512×512 app icon |
Modified: server/
| File | Change |
|---|---|
server/src/schema/patient_files.ts | Add source pgEnum + operatoryRoom varchar columns |
server/src/schema/clinical_notes.ts | Add attachedFileIds jsonb column |
server/src/schema/api_keys.ts | New: bridge API key table |
server/src/schema/index.ts | Export api_keys |
server/src/durable-objects/clinic-hub.ts | New: Durable Object for SSE event bus |
server/src/lib/event-bus.ts | New: helper to publish events via DO or in-memory |
server/src/routes/clinic-api-keys.ts | New: POST/DELETE /clinic/api-keys |
server/src/routes/sse.ts | New: GET /sse/clinic-events (streamSSE) |
server/src/routes/files.ts | Accept source/operatoryRoom/bridge tokens, fire event after insert |
server/src/api.ts | Mount new routes; export ClinicHub DO class |
server/src/worker.ts | Export ClinicHub for CF Workers binding |
server/wrangler.toml | Add [durable_objects] binding + [[migrations]] |
server/src/lib/nextauth.ts | Add generateBridgeToken + verifyBridgeToken |
Modified: ui/
| File | Change |
|---|---|
ui/src/contexts/ClinicEventsContext.tsx | New: EventSource context + emit/on API |
ui/src/hooks/useClinicEvents.ts | New: mount SSE, dispatch to context |
ui/src/components/notifications/XrayToast.tsx | New: live X-ray toast |
ui/src/App.tsx | Mount useClinicEvents() + <XrayToast /> inside auth scope |
ui/src/components/dental/OdontogramChart.tsx | Add X-ray badge dots on teeth that have files |
ui/src/components/appointments/AppointmentDetailPage.tsx | Add live X-ray panel for in_progress appointments |
ui/src/components/doctor/LabCaseDetailView.tsx | Add 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
- Step 2: Add attachedFileIds to clinical_notes
- Step 3: Push schema to DB
\d app.patient_files — should show source and operatory_room columns. \d app.clinical_notes should show attached_file_ids.
- Step 4: Commit
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
- Step 2: Export from schema index
server/src/schema/index.ts, after the last export * line, add:
- Step 3: Add bridge token helpers to nextauth.ts
server/src/lib/nextauth.ts:
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
- Step 5: Push schema to DB
api_keys table created in app schema.
- Step 6: Commit
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
- Step 2: Create event-bus helper
- Step 3: Create SSE route
- Step 4: Add Durable Object binding to wrangler.toml
server/wrangler.toml. After the [ai] section (around line 50), add:
[env.production]:
- Step 5: Commit
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
server/src/routes/files.ts, add this import at the top:
POST /upload handler, find the line:
const formData = await c.req.formData();, add extraction of new fields:
db.insert(patientFiles).values({...}) call (line ~246). Add the two new fields:
return c.json(...) line), add the event publish:
- Step 2: Mount new routes in api.ts
server/src/api.ts, add imports near the other route imports:
protectedRoutes.route('/files', filesRoute)). Add:
- Step 3: Export ClinicHub from worker.ts
server/src/worker.ts, add:
- Step 4: Commit
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
pnpm-workspace.yaml:
- Step 2: Create bridge/package.json
- Step 3: Create bridge/tsconfig.json
- Step 4: Create bridge/electron-builder.config.js
- Step 5: Install dependencies
node_modules/ populated, including electron, better-sqlite3, chokidar.
- Step 6: Create asset placeholders
- Step 7: Commit
Task 6 — Bridge: Store Module
Files:-
Create:
bridge/src/main/store.ts - Step 1: Create typed electron-store config
- Step 2: Commit
Task 7 — Bridge: SQLite Queue Module
Files:-
Create:
bridge/src/main/queue.ts - Step 1: Create queue with proper states
- Step 2: Commit
Task 8 — Bridge: File Watcher Module
Files:-
Create:
bridge/src/main/watcher.ts - Step 1: Create chokidar watcher
- Step 2: Commit
Task 9 — Bridge: Uploader + API Client
Files:-
Create:
bridge/src/main/uploader.ts - Step 1: Create uploader with retry and tray updates
- Step 2: Commit
Task 10 — Bridge: Tray + Main Process
Files:-
Create:
bridge/src/main/tray.ts -
Create:
bridge/src/main/index.ts - Step 1: Create tray module
- Step 2: Create main entry point
- Step 3: Create preload.ts
- Step 4: Commit
Task 11 — Bridge: Patient Selector Renderer
Files:-
Create:
bridge/src/renderer/patient-selector/index.html - Step 1: Create patient selector window
- Step 2: Build and test
- Step 3: Commit
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
- Step 2: Create SSE hook
- Step 3: Mount in App.tsx
ui/src/App.tsx, find the imports and add:
<ClinicEventsProvider>:
ClinicEventsMount component and render it inside the authenticated route scope:
<ClinicEventsMount /> inside the authenticated section of the router (after the auth check, before <AppLayout>).
- Step 4: Commit
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
- Step 2: Mount in App.tsx
ui/src/App.tsx, add import:
<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
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
ui/src/lib/odontogram-api.ts, append:
- Step 2: Add xray badge state and SSE subscription to OdontogramChart
ui/src/components/dental/OdontogramChart.tsx, add imports near the top:
OdontogramChart component, add state and load logic. Find const [toothFiles, setToothFiles] = useState<any[]>([]); (line ~134) and add below it:
loadChart or initial data load useEffect and add:
useEffect:
- Step 3: Render badge dot on teeth with X-rays
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:
relative positioned (add relative to its className if not already there).
- Step 4: Commit
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
ui/src/components/appointments/AppointmentDetailPage.tsx, add imports:
- Step 2: Commit
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
- Step 2: Add recent X-rays query to the note form
- Step 3: Render X-ray attachment checkboxes
- Step 4: Include attachedFileIds in the save payload
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:
server/src/routes/clinical-notes.ts to accept and store attachedFileIds.
- Step 5: Commit
Task 17 — Frontend: Lab DICOM ZIP Uploader
Files:-
Modify:
ui/src/components/doctor/LabCaseDetailView.tsx - Step 1: Add JSZip dependency
- Step 2: Add Radiology Files section to LabCaseDetailView
ui/src/components/doctor/LabCaseDetailView.tsx, add imports:
- Step 3: Commit
Self-Review
Spec Coverage Check
| Spec Section | Task |
|---|---|
| Workflow A bridge app | Tasks 5–11 |
| Workflow B lab DICOM | Task 17 |
| Schema: source + operatoryRoom | Task 1 |
| Schema: attachedFileIds | Task 1 |
| Bridge API key endpoint | Task 2 |
| SSE endpoint | Task 3 |
| SSE event after upload | Task 4 |
| Bridge file watcher | Task 8 |
| Bridge offline queue | Task 7 |
| Bridge patient selector | Tasks 10–11 |
| Bridge retry/tray states | Tasks 9–10 |
| Frontend SSE hook | Task 12 |
| Live X-ray toast | Task 13 |
| Dental chart tooth badge | Task 14 |
| Appointment live panel | Task 15 |
| Clinical notes attachment | Task 16 |
| Lab DICOM upload | Task 17 |
| API key revocation | Task 2 |
| Bridge electron-builder | Task 5 |
Type Consistency
ClinicEventtype defined inserver/src/durable-objects/clinic-hub.tsand mirrored inui/src/contexts/ClinicEventsContext.tsx— keep in syncQueueStatus = 'pending' | 'done' | 'failed'used consistently acrossqueue.tsanduploader.tspatientFileSourceEnumvalues'manual_upload' | 'bridge_capture' | 'lab_dicom'match across schema, upload route, and bridge uploaderBridgeTokenPayload.sub= clinicId — used correctly in Task 4 bridge auth check
Known Gaps / Notes
- Icon assets:
bridge/assets/*.pngare empty placeholders — replace with real 16×16 tray icons and 512×512 app icon before first distribution build. - Task 16 exact file path: Step 1 asks you to
grepfor the clinical notes form component — the exact path depends on where notes are edited in the UI (could be insideOdontogramChart.tsxor a separate modal). - Task 3 CF Workers wrangler: After adding the
[durable_objects]binding, runwrangler deployonce to register the migration — the DO class must be deployed before it can be used. - Auto-updater:
electron-updaterexpects a RELEASES file athttps://updates.odontox.io/bridge/— set up an R2 bucket at that path or use GitHub Releases as the update host. - Bridge token in upload route: The bridge sends a
Bearertoken. The existingdualAuthMiddlewareon protected routes also verifies the bearer token as a session JWT. Add the bridge check before the session middleware fires, or add a bypass indualAuthMiddlewarewhentype === 'bridge'.

