@omniradiology/omnirad 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +438 -0
  2. package/app/api/ai-config/route.ts +131 -0
  3. package/app/api/ai-config/test/route.ts +49 -0
  4. package/app/api/auth/auto-login/route.ts +66 -0
  5. package/app/api/auth/check/route.ts +17 -0
  6. package/app/api/auth/login/route.ts +72 -0
  7. package/app/api/auth/logout/route.ts +25 -0
  8. package/app/api/auth/me/route.ts +75 -0
  9. package/app/api/auth/password/route.ts +49 -0
  10. package/app/api/auth/setup/route.ts +63 -0
  11. package/app/api/auth/users/route.ts +100 -0
  12. package/app/api/auth/wipe/route.ts +27 -0
  13. package/app/api/compliance/anonymize/patient/[id]/route.ts +104 -0
  14. package/app/api/compliance/audit/route.ts +110 -0
  15. package/app/api/compliance/export/patient/[id]/route.ts +108 -0
  16. package/app/api/compliance/restrict/patient/[id]/route.ts +59 -0
  17. package/app/api/compliance/settings/route.ts +93 -0
  18. package/app/api/copilot/annotate/route.ts +94 -0
  19. package/app/api/copilot/chat/route.ts +238 -0
  20. package/app/api/copilot/history/route.ts +95 -0
  21. package/app/api/copilot/reports/route.ts +81 -0
  22. package/app/api/fhir/Bundle/report/[id]/route.ts +85 -0
  23. package/app/api/fhir/DiagnosticReport/[id]/route.ts +45 -0
  24. package/app/api/fhir/ImagingStudy/[id]/route.ts +57 -0
  25. package/app/api/fhir/Patient/[id]/route.ts +26 -0
  26. package/app/api/fhir/ServiceRequest/route.ts +85 -0
  27. package/app/api/fhir/config/route.ts +102 -0
  28. package/app/api/fhir/config/test-connection/route.ts +49 -0
  29. package/app/api/fhir/metadata/route.ts +51 -0
  30. package/app/api/pacs/metadata/route.ts +32 -0
  31. package/app/api/pacs/qido/instances/route.ts +39 -0
  32. package/app/api/pacs/qido/series/route.ts +38 -0
  33. package/app/api/pacs/qido/studies/route.ts +37 -0
  34. package/app/api/pacs/test/route.ts +30 -0
  35. package/app/api/pacs/wado/render/route.ts +51 -0
  36. package/app/api/patients/[id]/reports/route.ts +18 -0
  37. package/app/api/patients/[id]/route.ts +43 -0
  38. package/app/api/patients/merge/route.ts +57 -0
  39. package/app/api/patients/route.ts +67 -0
  40. package/app/api/patients/search/route.ts +25 -0
  41. package/app/api/reports/[id]/route.ts +84 -0
  42. package/app/api/reports/[id]/status/route.ts +87 -0
  43. package/app/api/reports/clear/route.ts +16 -0
  44. package/app/api/reports/route.ts +112 -0
  45. package/app/api/segmentation-config/route.ts +238 -0
  46. package/app/api/settings/route.ts +245 -0
  47. package/app/api/settings/test-supabase/route.ts +103 -0
  48. package/app/api/upload/route.ts +48 -0
  49. package/app/copilot/page.tsx +30 -0
  50. package/app/globals.css +141 -0
  51. package/app/history/page.tsx +242 -0
  52. package/app/icon.svg +3 -0
  53. package/app/layout.tsx +47 -0
  54. package/app/login/page.tsx +175 -0
  55. package/app/pacs/page.tsx +78 -0
  56. package/app/page.tsx +125 -0
  57. package/app/patients/[id]/page.tsx +315 -0
  58. package/app/patients/page.tsx +110 -0
  59. package/app/profile/page.tsx +208 -0
  60. package/app/reports/page.tsx +432 -0
  61. package/app/settings/page.tsx +454 -0
  62. package/app/setup/page.tsx +199 -0
  63. package/components/admin/AuditLogTable.tsx +293 -0
  64. package/components/copilot/ActivityIndicator.tsx +215 -0
  65. package/components/copilot/ChatHistoryPanel.tsx +140 -0
  66. package/components/copilot/ChatMessage.tsx +251 -0
  67. package/components/copilot/ClickableReference.tsx +40 -0
  68. package/components/copilot/CopilotCornerstoneViewer.tsx +562 -0
  69. package/components/copilot/CopilotPanel.tsx +311 -0
  70. package/components/copilot/FindingsList.tsx +75 -0
  71. package/components/copilot/ViewerPanel.tsx +460 -0
  72. package/components/copilot/WorkspaceLayout.tsx +398 -0
  73. package/components/dashboard/AIConfigPanel.tsx +339 -0
  74. package/components/dashboard/AppearancePanel.tsx +491 -0
  75. package/components/dashboard/ApprovalModal.tsx +163 -0
  76. package/components/dashboard/CollaborationPanel.tsx +134 -0
  77. package/components/dashboard/CopilotConfigPanel.tsx +337 -0
  78. package/components/dashboard/DicomViewer.tsx +645 -0
  79. package/components/dashboard/FhirIntegrationPanel.tsx +331 -0
  80. package/components/dashboard/FullReportOverlay.tsx +269 -0
  81. package/components/dashboard/ImageViewer.tsx +541 -0
  82. package/components/dashboard/PatientForm.tsx +597 -0
  83. package/components/dashboard/RejectionModal.tsx +74 -0
  84. package/components/dashboard/ReportEditor.tsx +160 -0
  85. package/components/dashboard/ReportTemplates.tsx +729 -0
  86. package/components/dashboard/ReportView.tsx +539 -0
  87. package/components/dashboard/SegmentationConfigPanel.tsx +490 -0
  88. package/components/dashboard/StudyPlaceholder.tsx +17 -0
  89. package/components/dashboard/SupabaseIntegrationPanel.tsx +345 -0
  90. package/components/dashboard/UserManagementPanel.tsx +272 -0
  91. package/components/layout/ClientLayout.tsx +39 -0
  92. package/components/layout/Header.tsx +20 -0
  93. package/components/layout/Sidebar.tsx +119 -0
  94. package/components/pacs/PacsImageViewerModal.tsx +121 -0
  95. package/components/pacs/PacsSearchFilters.tsx +117 -0
  96. package/components/pacs/PacsSeriesViewer.tsx +190 -0
  97. package/components/pacs/PacsStudyTable.tsx +113 -0
  98. package/components/patients/patient-card.tsx +117 -0
  99. package/components/patients/patient-header.tsx +122 -0
  100. package/components/patients/patient-search.tsx +137 -0
  101. package/components/patients/patient-timeline.tsx +153 -0
  102. package/components/settings/ComplianceSettingsPanel.tsx +278 -0
  103. package/components/settings/SecurityPanel.tsx +418 -0
  104. package/components/ui/badge.tsx +19 -0
  105. package/components/ui/basic.tsx +156 -0
  106. package/db/index.ts +350 -0
  107. package/db/migrations/0000_odd_quasimodo.sql +117 -0
  108. package/db/migrations/meta/0000_snapshot.json +778 -0
  109. package/db/migrations/meta/_journal.json +13 -0
  110. package/db/schema.ts +239 -0
  111. package/drizzle.config.ts +10 -0
  112. package/lib/api.ts +689 -0
  113. package/lib/auth.ts +22 -0
  114. package/lib/copilot/action-executor.ts +94 -0
  115. package/lib/copilot/action-types.ts +72 -0
  116. package/lib/copilot/coordinate-mapper.ts +84 -0
  117. package/lib/dicomImageExtractor.ts +103 -0
  118. package/lib/dicomMetadataParser.ts +111 -0
  119. package/lib/fhir/client.ts +25 -0
  120. package/lib/fhir/constants.ts +21 -0
  121. package/lib/fhir/diagnostic-report.ts +88 -0
  122. package/lib/fhir/helpers.ts +73 -0
  123. package/lib/fhir/imaging-study.ts +49 -0
  124. package/lib/fhir/patient.ts +55 -0
  125. package/lib/fhir/service-request.ts +85 -0
  126. package/lib/fhir.ts +6 -0
  127. package/lib/pacs/dicom-utils.ts +72 -0
  128. package/lib/pacs/dicomweb.ts +72 -0
  129. package/lib/pacs/server-utils.ts +37 -0
  130. package/lib/patients.ts +25 -0
  131. package/lib/pdfHelper.ts +119 -0
  132. package/lib/reportHtmlGenerator.ts +581 -0
  133. package/lib/security/audit.ts +180 -0
  134. package/lib/security/authz.ts +246 -0
  135. package/lib/security/phi-redaction.ts +156 -0
  136. package/lib/security/rate-limit.ts +106 -0
  137. package/lib/security/secrets.ts +179 -0
  138. package/lib/supabase.ts +72 -0
  139. package/lib/utils.ts +6 -0
  140. package/next.config.ts +35 -0
  141. package/package.json +76 -0
  142. package/public/file.svg +1 -0
  143. package/public/globe.svg +1 -0
  144. package/public/logo.svg +8 -0
  145. package/public/next.svg +1 -0
  146. package/public/omnirad-favicon.svg +8 -0
  147. package/public/vercel.svg +1 -0
  148. package/public/window.svg +1 -0
  149. package/tsconfig.json +34 -0
  150. package/types/copilot-viewer.ts +155 -0
  151. package/types/copilot.ts +105 -0
  152. package/types/fhir.ts +21 -0
  153. package/types/html2pdf.d.ts +20 -0
  154. package/types/index.ts +139 -0
  155. package/types/pacs.ts +41 -0
package/lib/api.ts ADDED
@@ -0,0 +1,689 @@
1
+ import { PatientContext, ReportData, ReportStatus } from "@/types";
2
+ import { getSupabaseClient, ensureSupabaseConfig } from "./supabase";
3
+
4
+ // ─── Helper: fetch settings from the local SQLite API ────────────────────────
5
+ async function fetchSettings(type: string) {
6
+ try {
7
+ const res = await fetch(`/api/settings?type=${type}`);
8
+ if (res.ok) return await res.json();
9
+ } catch (e) {
10
+ console.warn(`[OmniRad] Could not fetch settings/${type}:`, e);
11
+ }
12
+ return null;
13
+ }
14
+
15
+ // ─── Generate Report ─────────────────────────────────────────────────────────
16
+ export async function generateReport(data: PatientContext, dicomBase64?: string | null, dicomSlices?: string[]): Promise<ReportData[]> {
17
+ // 1. Send to local Python FastAPI Microservice
18
+ let webhookUrl: string = "http://localhost:8001/generate_report";
19
+ console.log("[OmniRad] Using backend Python microservice at:", webhookUrl);
20
+
21
+
22
+ try {
23
+ const formData = new FormData();
24
+ formData.append("patient_name", data.fullName);
25
+ formData.append("patient_age", String(data.age));
26
+ formData.append("patient_gender", data.gender);
27
+ formData.append("symptoms", data.symptoms);
28
+ formData.append("history", data.history);
29
+ formData.append("indication", data.indication);
30
+ formData.append("modality", data.modality);
31
+
32
+ formData.append("isDicom", data.isDicom ? "true" : "false");
33
+ if (data.isDicom && data.dicomMetadata) {
34
+ formData.append("dicomMetadata", JSON.stringify(data.dicomMetadata));
35
+ }
36
+
37
+ let imageBase64: string | null = null;
38
+ let imagesBase64: string[] = [];
39
+
40
+ const filesToProcess = data.images && data.images.length > 0 ? data.images : (data.image ? [data.image] : []);
41
+
42
+ if (data.isPacs && data.pacsData) {
43
+ formData.append("isPacs", "true");
44
+ formData.append("pacsMetadata", JSON.stringify(data.pacsData));
45
+
46
+ try {
47
+ // Fetch the default rendered representation by omitting frame parameter (handles single-frame and defaults to first frame for multi-frame)
48
+ const url = `/api/pacs/wado/render?studyUid=${data.pacsData.pacsStudyUid}&seriesUid=${data.pacsData.pacsSeriesUid}&instanceUid=${data.pacsData.firstInstanceUid}`;
49
+ const res = await fetch(url);
50
+ if (res.ok) {
51
+ const blob = await res.blob();
52
+ formData.append("image", blob, `pacs-instance.jpg`);
53
+
54
+ const b64 = await new Promise<string>((resolve, reject) => {
55
+ const reader = new FileReader();
56
+ reader.onload = () => resolve(reader.result as string);
57
+ reader.onerror = reject;
58
+ reader.readAsDataURL(blob);
59
+ });
60
+ imageBase64 = b64;
61
+ imagesBase64.push(b64);
62
+ }
63
+ } catch (e) {
64
+ console.error("[OmniRad] Error fetching PACS image:", e);
65
+ }
66
+ } else if (data.isDicom && dicomSlices && dicomSlices.length > 0) {
67
+ // Multi-slice DICOM: send each captured slice as a separate binary image
68
+ formData.append("sliceCount", String(dicomSlices.length));
69
+ for (let i = 0; i < dicomSlices.length; i++) {
70
+ const response = await fetch(dicomSlices[i]);
71
+ const blob = await response.blob();
72
+ formData.append(i === 0 ? "image" : `image_${i}`, blob, `dicom-slice-${i + 1}.jpg`);
73
+ imagesBase64.push(dicomSlices[i]);
74
+ if (i === 0) imageBase64 = dicomSlices[i];
75
+ }
76
+ console.log(`[OmniRad] Sending ${dicomSlices.length} DICOM slices to webhook`);
77
+ } else if (data.isDicom && dicomBase64) {
78
+ // Single-frame DICOM fallback
79
+ const response = await fetch(dicomBase64);
80
+ const blob = await response.blob();
81
+ formData.append("image", blob, "dicom-preview.jpg");
82
+ imageBase64 = dicomBase64;
83
+ imagesBase64.push(dicomBase64);
84
+ } else {
85
+ for (let i = 0; i < filesToProcess.length; i++) {
86
+ const file = filesToProcess[i] as File;
87
+ formData.append(i === 0 ? "image" : `image_${i}`, file, file.name);
88
+
89
+ const b64 = await new Promise<string>((resolve, reject) => {
90
+ const reader = new FileReader();
91
+ reader.onload = () => resolve(reader.result as string);
92
+ reader.onerror = reject;
93
+ reader.readAsDataURL(file);
94
+ });
95
+ imagesBase64.push(b64);
96
+ if (i === 0) imageBase64 = b64;
97
+ }
98
+ }
99
+
100
+ // Load active AI configuration (with real API key) for forwarding to Python backend
101
+ let aiConfig: any = null;
102
+ try {
103
+ const configRes = await fetch('/api/ai-config?mode=active_internal');
104
+ if (configRes.ok) {
105
+ aiConfig = await configRes.json();
106
+ console.log("[OmniRad] Loaded active AI config:", aiConfig.providerName, aiConfig.modelName);
107
+ } else {
108
+ console.warn("[OmniRad] No active AI configuration found. The Python backend may fail.");
109
+ }
110
+ } catch (e) {
111
+ console.warn("[OmniRad] Could not fetch AI config:", e);
112
+ }
113
+
114
+ // Load profile info for report header
115
+ let hospitalName = 'OmniRad Hospital';
116
+ let department = 'Radiology';
117
+ try {
118
+ const profileData = await fetchSettings("profile");
119
+ if (profileData) {
120
+ if (profileData.hospitalName) hospitalName = profileData.hospitalName;
121
+ if (profileData.department) department = profileData.department;
122
+ }
123
+ } catch (e) { /* ignore */ }
124
+
125
+ // Create proper JSON payload for Python Backend
126
+ const payload = {
127
+ patient: {
128
+ name: data.fullName,
129
+ age: data.age,
130
+ dob: data.dob,
131
+ gender: data.gender,
132
+ patient_id: data.patientId || ""
133
+ },
134
+ clinical_information: {
135
+ symptoms: data.symptoms,
136
+ history: data.history,
137
+ indication: data.indication
138
+ },
139
+ study: {
140
+ modality: data.modality,
141
+ is_dicom: data.isDicom,
142
+ is_pacs: data.isPacs
143
+ },
144
+ image: imageBase64 ? { type: "base64", data: imageBase64 } : null,
145
+ report_header: { hospital_name: hospitalName, department: department },
146
+ ai_config: aiConfig ? {
147
+ providerType: aiConfig.providerType,
148
+ providerName: aiConfig.providerName,
149
+ apiEndpointUrl: aiConfig.apiEndpointUrl,
150
+ apiSecretKey: aiConfig.apiSecretKey,
151
+ modelName: aiConfig.modelName,
152
+ maxTokens: aiConfig.maxTokens,
153
+ temperature: aiConfig.temperature,
154
+ timeoutSeconds: aiConfig.timeoutSeconds,
155
+ isVisionCapable: aiConfig.isVisionCapable,
156
+ } : null
157
+ };
158
+
159
+ console.log("[OmniRad] Sending request to Python Backend:", webhookUrl);
160
+
161
+ const response = await fetch(webhookUrl, {
162
+ method: "POST",
163
+ headers: {
164
+ "Content-Type": "application/json"
165
+ },
166
+ body: JSON.stringify(payload),
167
+ });
168
+
169
+ console.log("[OmniRad] Backend response status:", response.status, response.statusText);
170
+
171
+ if (!response.ok) {
172
+ const errorText = await response.text().catch(() => 'Could not read error body');
173
+ console.error("[OmniRad] Backend error response body:", errorText);
174
+ throw new Error(`API call failed: ${response.status} ${response.statusText}`);
175
+ }
176
+
177
+ const rawResponse = await response.json();
178
+ console.log("[OmniRad] Backend raw response:", rawResponse);
179
+
180
+ // Check if the Python backend returned an error
181
+ if (rawResponse.failed || rawResponse.error) {
182
+ throw new Error(rawResponse.error || 'AI generation failed');
183
+ }
184
+
185
+ // Handle various response formats
186
+ let reports: ReportData[];
187
+ if (Array.isArray(rawResponse)) {
188
+ reports = rawResponse;
189
+ } else if (rawResponse && typeof rawResponse === 'object') {
190
+ if (rawResponse.output && typeof rawResponse.output === 'object') {
191
+ reports = Array.isArray(rawResponse.output) ? rawResponse.output : [rawResponse.output];
192
+ } else if (rawResponse.data && typeof rawResponse.data === 'object') {
193
+ reports = Array.isArray(rawResponse.data) ? rawResponse.data : [rawResponse.data];
194
+ } else if (rawResponse.report_header || rawResponse.patient || rawResponse.findings) {
195
+ reports = [rawResponse as ReportData];
196
+ } else {
197
+ console.warn("[OmniRad] Unexpected response format:", rawResponse);
198
+ reports = [rawResponse as ReportData];
199
+ }
200
+ } else {
201
+ throw new Error('Invalid response format from webhook');
202
+ }
203
+
204
+ console.log("[OmniRad] Parsed reports count:", reports.length);
205
+
206
+ const normalizeStatus = (status: string | undefined): ReportStatus => {
207
+ if (!status) return 'Pending';
208
+ const upper = status.toUpperCase().trim();
209
+ if (upper === 'APPROVED') return 'Approved';
210
+ if (upper === 'REJECTED') return 'Rejected';
211
+ if (upper === 'FINAL') return 'Final';
212
+ return 'Pending';
213
+ };
214
+
215
+ // Load profile info for report footer defaults
216
+ let defaultPreparedBy = 'OmniRad AI';
217
+ let defaultDepartment = 'Radiology';
218
+ let defaultHospitalName = 'Hospital';
219
+ try {
220
+ const profileData = await fetchSettings("profile");
221
+ if (profileData) {
222
+ if (profileData.fullName) defaultPreparedBy = profileData.fullName;
223
+ if (profileData.department) defaultDepartment = profileData.department;
224
+ if (profileData.hospitalName) defaultHospitalName = profileData.hospitalName;
225
+ }
226
+ } catch (e) { /* ignore */ }
227
+
228
+ // Ensure report has required structure
229
+ reports = reports.map(report => {
230
+ const footer = report.report_footer || {};
231
+ return {
232
+ report_header: report.report_header || {
233
+ hospital_name: defaultHospitalName,
234
+ department: defaultDepartment,
235
+ report_title: 'Radiology Report',
236
+ report_id: `RAD-${Date.now()}`,
237
+ report_date: new Date().toISOString(),
238
+ },
239
+ patient: {
240
+ name: report.patient?.name || data.fullName || 'Unknown Patient',
241
+ patient_id: data.patientId || report.patient?.patient_id || '',
242
+ age: report.patient?.age || data.age || 0,
243
+ gender: report.patient?.gender || data.gender || 'Unknown'
244
+ },
245
+ clinical_information: report.clinical_information || {
246
+ symptoms: data.symptoms,
247
+ history: data.history,
248
+ indication: data.indication,
249
+ },
250
+ study: report.study || { modality: data.modality, examination: `${data.modality} Scan`, views: 'Standard Views' },
251
+ findings: report.findings || [],
252
+ impression: report.impression || [],
253
+ urgency: report.urgency || 'Routine',
254
+ recommendations: report.recommendations || [],
255
+ report_footer: {
256
+ prepared_by: footer.prepared_by || defaultPreparedBy,
257
+ department: footer.department || defaultDepartment,
258
+ report_status: normalizeStatus(footer.report_status),
259
+ approved_by: footer.approved_by,
260
+ approved_at: footer.approved_at,
261
+ signature: footer.signature,
262
+ rejection_reason: footer.rejection_reason,
263
+ },
264
+ disclaimer: report.disclaimer || 'This AI-generated report is for reference only and must be verified by a licensed radiologist.',
265
+ image_data: report.image_data,
266
+ images_data: report.images_data,
267
+ collaboration: report.collaboration,
268
+ pacs_info: data.isPacs && data.pacsData ? {
269
+ study_uid: data.pacsData.pacsStudyUid,
270
+ series_uid: data.pacsData.pacsSeriesUid,
271
+ source: data.pacsData.pacsSource || 'Orthanc'
272
+ } : report.pacs_info,
273
+ };
274
+ });
275
+
276
+ // Attach true base64 image data (overriding any string descriptors sent back by the AI webhook)
277
+ if (reports.length > 0) {
278
+ if (imageBase64) reports[0].image_data = imageBase64;
279
+ if (imagesBase64.length > 0) reports[0].images_data = imagesBase64;
280
+ }
281
+
282
+ // Save the first report
283
+ if (reports.length > 0) {
284
+ await saveReport(reports[0]);
285
+ }
286
+
287
+ return reports;
288
+ } catch (error) {
289
+ console.error("[OmniRad] Report generation error:", error);
290
+ throw error;
291
+ }
292
+ }
293
+
294
+ // ─── Save Report ─────────────────────────────────────────────────────────────
295
+ export async function saveReport(report: ReportData) {
296
+ const reportId = `local_${Date.now()}`;
297
+ let linkedPatientId: string | null = null;
298
+
299
+ // 1. Save to local SQLite via API
300
+ try {
301
+ const res = await fetch('/api/reports', {
302
+ method: 'POST',
303
+ headers: { 'Content-Type': 'application/json' },
304
+ body: JSON.stringify({ id: reportId, report_data: report }),
305
+ });
306
+ if (res.ok) {
307
+ const data = await res.json();
308
+ linkedPatientId = data.patientId || null;
309
+ console.log("Report saved to SQLite:", reportId, "Linked Patient:", linkedPatientId);
310
+ } else {
311
+ console.error("Error saving to SQLite:", await res.text());
312
+ }
313
+ } catch (err) {
314
+ console.error("Error saving to SQLite:", err);
315
+ }
316
+
317
+ // 2. Then save to Supabase (if configured)
318
+ await ensureSupabaseConfig();
319
+ const supabase = getSupabaseClient();
320
+ if (!supabase) {
321
+ console.warn("Supabase not configured. Report saved locally only.");
322
+ return null;
323
+ }
324
+
325
+ // Upsert Patient to Supabase first if we linked one locally
326
+ if (linkedPatientId) {
327
+ try {
328
+ const pRes = await fetch(`/api/patients/${linkedPatientId}`);
329
+ if (pRes.ok) {
330
+ const pData = await pRes.json();
331
+ const { error: pErr } = await supabase.from('patients').upsert({
332
+ id: pData.id,
333
+ patient_name: pData.patientName,
334
+ patient_id_number: pData.patientIdNumber,
335
+ date_of_birth: pData.dob,
336
+ gender: pData.gender,
337
+ contact_info: pData.contactInfo,
338
+ notes: pData.notes,
339
+ created_at: pData.createdAt,
340
+ updated_at: pData.updatedAt
341
+ }, { onConflict: 'id' });
342
+ if (pErr) console.error("Error syncing patient to Supabase:", pErr);
343
+ }
344
+ } catch (e) {
345
+ console.error("Error fetching local patient for Supabase sync:", e);
346
+ }
347
+ }
348
+
349
+ // Strip heavy base64 image data to save Supabase storage space
350
+ const cloudReportData = { ...report };
351
+ delete cloudReportData.image_data;
352
+ delete cloudReportData.images_data;
353
+
354
+ try {
355
+ const { data, error } = await supabase.from('reports').insert({
356
+ patient_id: linkedPatientId,
357
+ patient_name: report.patient.name,
358
+ modality: report.study.examination,
359
+ urgency: report.urgency,
360
+ report_status: report.report_footer?.report_status || 'Pending',
361
+ report_data: cloudReportData,
362
+ pacs_study_uid: report.pacs_info?.study_uid || null,
363
+ pacs_series_uid: report.pacs_info?.series_uid || null,
364
+ pacs_source: report.pacs_info?.source || null,
365
+ created_at: new Date().toISOString()
366
+ }).select();
367
+
368
+ if (error) {
369
+ console.error("Error saving report to Supabase:", {
370
+ message: error.message,
371
+ code: error.code,
372
+ details: error.details,
373
+ hint: error.hint
374
+ });
375
+
376
+ if (error.code === '42501') {
377
+ console.warn("⚠️ PERMISSION DENIED: RLS issue. Run the SQL setup script.");
378
+ }
379
+ return null;
380
+ }
381
+
382
+ console.log("Report saved to Supabase:", data);
383
+ return data;
384
+ } catch (err) {
385
+ console.error("Exception saving report to Supabase:", err);
386
+ return null;
387
+ }
388
+ }
389
+
390
+ // ─── Get Reports ─────────────────────────────────────────────────────────────
391
+ export async function getReports() {
392
+ // 1. Get reports from local SQLite
393
+ let localReports: any[] = [];
394
+ try {
395
+ const res = await fetch('/api/reports');
396
+ if (res.ok) {
397
+ localReports = await res.json();
398
+ }
399
+ } catch (err) {
400
+ console.error("Error loading from SQLite:", err);
401
+ }
402
+
403
+ // 2. Get reports from Supabase
404
+ await ensureSupabaseConfig();
405
+ const supabase = getSupabaseClient();
406
+ let supabaseReports: any[] = [];
407
+
408
+ if (supabase) {
409
+ const { data, error } = await supabase
410
+ .from('reports')
411
+ .select('*')
412
+ .order('created_at', { ascending: false });
413
+
414
+ if (error) {
415
+ console.error("Error fetching reports from Supabase:", error);
416
+ } else {
417
+ supabaseReports = data || [];
418
+ }
419
+ }
420
+
421
+ // 3. Merge both sources (Supabase reports first, then local-only reports)
422
+ const allReports = [];
423
+ const existingIds = new Set();
424
+ const localReportsMap = new Map();
425
+
426
+ // Map local reports by ID for quick lookup
427
+ for (const local of localReports) {
428
+ const localId = local.report_data?.report_header?.report_id || local.id;
429
+ localReportsMap.set(localId, local);
430
+ }
431
+
432
+ // Process Supabase reports, merging in image_data from local if it's missing
433
+ for (const sRep of supabaseReports) {
434
+ // Create a shallow copy so we can mutate report_data
435
+ const rep = { ...sRep, _source: 'Supabase' };
436
+ const repId = rep.report_data?.report_header?.report_id || rep.id;
437
+
438
+ // If Supabase report is missing image_data, see if we have it locally
439
+ if (localReportsMap.has(repId)) {
440
+ rep._source = 'Synced';
441
+ const local = localReportsMap.get(repId);
442
+ if (local.report_data?.image_data) {
443
+ // Also clone report_data to avoid mutating the original fetched object
444
+ rep.report_data = { ...rep.report_data, image_data: local.report_data.image_data };
445
+ }
446
+ }
447
+
448
+ allReports.push(rep);
449
+ existingIds.add(repId);
450
+ }
451
+
452
+ // Add remaining local-only reports
453
+ for (const local of localReports) {
454
+ const localId = local.report_data?.report_header?.report_id || local.id;
455
+ if (!existingIds.has(localId)) {
456
+ allReports.push({ ...local, _source: 'Local' });
457
+ existingIds.add(localId);
458
+ }
459
+ }
460
+
461
+ console.log(`Loaded ${supabaseReports.length} from Supabase, ${localReports.length} from SQLite. Total deduplicated: ${allReports.length}`);
462
+
463
+ return allReports;
464
+ }
465
+
466
+ // ─── Update Report Data ──────────────────────────────────────────────────────
467
+ export async function updateReportData(id: string, updates: Partial<ReportData>) {
468
+ // Update local SQLite
469
+ try {
470
+ await fetch(`/api/reports/${encodeURIComponent(id)}`, {
471
+ method: 'PUT',
472
+ headers: { 'Content-Type': 'application/json' },
473
+ body: JSON.stringify({ updates }),
474
+ });
475
+ } catch (err) {
476
+ console.error("Error updating SQLite report:", err);
477
+ }
478
+
479
+ // Also update Supabase if configured
480
+ await ensureSupabaseConfig();
481
+ const supabase = getSupabaseClient();
482
+ if (!supabase || id.startsWith('local_')) return true;
483
+
484
+ const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
485
+ let supabaseRowId: string | null = isUUID ? id : null;
486
+
487
+ if (!supabaseRowId) {
488
+ const { data: found } = await supabase
489
+ .from('reports')
490
+ .select('id')
491
+ .filter('report_data->report_header->>report_id', 'eq', id)
492
+ .limit(1)
493
+ .single();
494
+ if (found) {
495
+ supabaseRowId = found.id;
496
+ } else {
497
+ return true; // Local update succeeded
498
+ }
499
+ }
500
+
501
+ const { data: current, error: fetchError } = await supabase
502
+ .from('reports')
503
+ .select('report_data')
504
+ .eq('id', supabaseRowId)
505
+ .single();
506
+
507
+ if (fetchError || !current) return true;
508
+
509
+ const updatedData = { ...current.report_data, ...updates };
510
+
511
+ // Strip base64 image data to ensure it never accidentally inflates the cloud row size during updates
512
+ delete updatedData.image_data;
513
+ delete updatedData.images_data;
514
+
515
+ const { error } = await supabase
516
+ .from('reports')
517
+ .update({ report_data: updatedData })
518
+ .eq('id', supabaseRowId);
519
+
520
+ return !error;
521
+ }
522
+
523
+ // ─── Update Report Status ────────────────────────────────────────────────────
524
+ export async function updateReportStatus(
525
+ id: string,
526
+ status: ReportStatus,
527
+ data?: { signature?: string, rejectionReason?: string, notes?: string }
528
+ ) {
529
+ await ensureSupabaseConfig();
530
+ const supabase = getSupabaseClient();
531
+
532
+ // Get current user info from profile
533
+ let userName = "System";
534
+ let userRole = "System";
535
+ try {
536
+ const res = await fetch('/api/auth/me');
537
+ if (res.ok) {
538
+ const data = await res.json();
539
+ if (data.fullName) userName = data.fullName;
540
+ if (data.position || data.role) userRole = data.position || data.role;
541
+ }
542
+ } catch (e) { /* ignore */ }
543
+
544
+ // 1. Update local SQLite via API
545
+ let localSuccess = false;
546
+ try {
547
+ const res = await fetch(`/api/reports/${encodeURIComponent(id)}/status`, {
548
+ method: 'PATCH',
549
+ headers: { 'Content-Type': 'application/json' },
550
+ body: JSON.stringify({
551
+ status,
552
+ signature: data?.signature,
553
+ rejectionReason: data?.rejectionReason,
554
+ notes: data?.notes,
555
+ userName,
556
+ userRole,
557
+ }),
558
+ });
559
+ localSuccess = res.ok;
560
+ if (res.ok) {
561
+ console.log("[OmniRad] SQLite report status updated:", status);
562
+ }
563
+ } catch (err) {
564
+ console.error("[OmniRad] Error updating SQLite status:", err);
565
+ }
566
+
567
+ // 2. Update Supabase (if configured)
568
+ const isLocalReport = id.startsWith('local_');
569
+ const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
570
+ let supabaseRowId: string | null = isUUID ? id : null;
571
+
572
+ if (supabase && !isLocalReport) {
573
+ // Resolve non-UUID ids (like RAD-XXXX)
574
+ let updatedData: any = null;
575
+
576
+ if (!supabaseRowId) {
577
+ console.log("[OmniRad] Looking up Supabase row by report_id:", id);
578
+ const { data: found, error: lookupError } = await supabase
579
+ .from('reports')
580
+ .select('id, report_data')
581
+ .filter('report_data->report_header->>report_id', 'eq', id)
582
+ .limit(1)
583
+ .single();
584
+
585
+ if (!lookupError && found) {
586
+ supabaseRowId = found.id;
587
+ updatedData = { ...found.report_data };
588
+ console.log("[OmniRad] Found Supabase row UUID:", supabaseRowId);
589
+ } else {
590
+ console.warn("[OmniRad] Could not find Supabase row for report_id:", id);
591
+ }
592
+ } else {
593
+ const { data: current } = await supabase
594
+ .from('reports')
595
+ .select('report_data')
596
+ .eq('id', supabaseRowId)
597
+ .single();
598
+ if (current) updatedData = { ...current.report_data };
599
+ }
600
+
601
+ if (updatedData && supabaseRowId) {
602
+ // Apply status changes
603
+ updatedData.report_footer.report_status = status;
604
+ if (!updatedData.collaboration) updatedData.collaboration = { comments: [], logs: [] };
605
+
606
+ const timestamp = new Date().toISOString();
607
+ updatedData.collaboration.logs.push({
608
+ id: `log_${Date.now()}`,
609
+ action: `Status Changed to ${status}`,
610
+ user: userName,
611
+ timestamp,
612
+ details: status === 'Rejected' ? `Reason: ${data?.rejectionReason}` :
613
+ status === 'Approved' ? 'Report Approved' : 'Status reset'
614
+ });
615
+
616
+ if (data?.notes || data?.rejectionReason) {
617
+ updatedData.collaboration.comments.push({
618
+ id: `comment_${Date.now()}`,
619
+ author: userName,
620
+ role: userRole,
621
+ text: data?.notes || data?.rejectionReason || "",
622
+ timestamp,
623
+ });
624
+ }
625
+
626
+ if (status === 'Approved') {
627
+ updatedData.report_footer.approved_at = timestamp;
628
+ if (data?.signature) updatedData.report_footer.signature = data.signature;
629
+ updatedData.report_footer.approved_by = userName;
630
+ }
631
+
632
+ if (status === 'Rejected' && data?.rejectionReason) {
633
+ updatedData.report_footer.rejection_reason = data.rejectionReason;
634
+ }
635
+
636
+ // Try update with report_status column
637
+ const { error: err1 } = await supabase
638
+ .from('reports')
639
+ .update({ report_data: updatedData, report_status: status })
640
+ .eq('id', supabaseRowId);
641
+
642
+ if (err1) {
643
+ console.warn("[OmniRad] Supabase update with report_status failed:", err1.message);
644
+ // Fallback without the column
645
+ const { error: err2 } = await supabase
646
+ .from('reports')
647
+ .update({ report_data: updatedData })
648
+ .eq('id', supabaseRowId);
649
+ if (err2) {
650
+ console.error("[OmniRad] Supabase fallback update also failed:", err2.message);
651
+ } else {
652
+ console.log("[OmniRad] Fallback update succeeded (report_status column may be missing).");
653
+ }
654
+ } else {
655
+ console.log("[OmniRad] Supabase report status updated successfully:", status);
656
+ }
657
+ }
658
+ }
659
+
660
+ return localSuccess;
661
+ }
662
+
663
+ // ─── Clear All Reports ───────────────────────────────────────────────────────
664
+ export async function clearAllReports() {
665
+ // 1. Clear local SQLite
666
+ try {
667
+ await fetch('/api/reports/clear', { method: 'DELETE' });
668
+ console.log("Cleared SQLite reports");
669
+ } catch (e) {
670
+ console.error("Error clearing SQLite:", e);
671
+ }
672
+
673
+ // 2. Clear Supabase
674
+ await ensureSupabaseConfig();
675
+ const supabase = getSupabaseClient();
676
+ if (supabase) {
677
+ const { error } = await supabase
678
+ .from('reports')
679
+ .delete()
680
+ .neq('id', '00000000-0000-0000-0000-000000000000');
681
+
682
+ if (error) {
683
+ console.error("Error clearing Supabase reports:", error);
684
+ return false;
685
+ }
686
+ }
687
+
688
+ return true;
689
+ }