@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.
- package/README.md +438 -0
- package/app/api/ai-config/route.ts +131 -0
- package/app/api/ai-config/test/route.ts +49 -0
- package/app/api/auth/auto-login/route.ts +66 -0
- package/app/api/auth/check/route.ts +17 -0
- package/app/api/auth/login/route.ts +72 -0
- package/app/api/auth/logout/route.ts +25 -0
- package/app/api/auth/me/route.ts +75 -0
- package/app/api/auth/password/route.ts +49 -0
- package/app/api/auth/setup/route.ts +63 -0
- package/app/api/auth/users/route.ts +100 -0
- package/app/api/auth/wipe/route.ts +27 -0
- package/app/api/compliance/anonymize/patient/[id]/route.ts +104 -0
- package/app/api/compliance/audit/route.ts +110 -0
- package/app/api/compliance/export/patient/[id]/route.ts +108 -0
- package/app/api/compliance/restrict/patient/[id]/route.ts +59 -0
- package/app/api/compliance/settings/route.ts +93 -0
- package/app/api/copilot/annotate/route.ts +94 -0
- package/app/api/copilot/chat/route.ts +238 -0
- package/app/api/copilot/history/route.ts +95 -0
- package/app/api/copilot/reports/route.ts +81 -0
- package/app/api/fhir/Bundle/report/[id]/route.ts +85 -0
- package/app/api/fhir/DiagnosticReport/[id]/route.ts +45 -0
- package/app/api/fhir/ImagingStudy/[id]/route.ts +57 -0
- package/app/api/fhir/Patient/[id]/route.ts +26 -0
- package/app/api/fhir/ServiceRequest/route.ts +85 -0
- package/app/api/fhir/config/route.ts +102 -0
- package/app/api/fhir/config/test-connection/route.ts +49 -0
- package/app/api/fhir/metadata/route.ts +51 -0
- package/app/api/pacs/metadata/route.ts +32 -0
- package/app/api/pacs/qido/instances/route.ts +39 -0
- package/app/api/pacs/qido/series/route.ts +38 -0
- package/app/api/pacs/qido/studies/route.ts +37 -0
- package/app/api/pacs/test/route.ts +30 -0
- package/app/api/pacs/wado/render/route.ts +51 -0
- package/app/api/patients/[id]/reports/route.ts +18 -0
- package/app/api/patients/[id]/route.ts +43 -0
- package/app/api/patients/merge/route.ts +57 -0
- package/app/api/patients/route.ts +67 -0
- package/app/api/patients/search/route.ts +25 -0
- package/app/api/reports/[id]/route.ts +84 -0
- package/app/api/reports/[id]/status/route.ts +87 -0
- package/app/api/reports/clear/route.ts +16 -0
- package/app/api/reports/route.ts +112 -0
- package/app/api/segmentation-config/route.ts +238 -0
- package/app/api/settings/route.ts +245 -0
- package/app/api/settings/test-supabase/route.ts +103 -0
- package/app/api/upload/route.ts +48 -0
- package/app/copilot/page.tsx +30 -0
- package/app/globals.css +141 -0
- package/app/history/page.tsx +242 -0
- package/app/icon.svg +3 -0
- package/app/layout.tsx +47 -0
- package/app/login/page.tsx +175 -0
- package/app/pacs/page.tsx +78 -0
- package/app/page.tsx +125 -0
- package/app/patients/[id]/page.tsx +315 -0
- package/app/patients/page.tsx +110 -0
- package/app/profile/page.tsx +208 -0
- package/app/reports/page.tsx +432 -0
- package/app/settings/page.tsx +454 -0
- package/app/setup/page.tsx +199 -0
- package/components/admin/AuditLogTable.tsx +293 -0
- package/components/copilot/ActivityIndicator.tsx +215 -0
- package/components/copilot/ChatHistoryPanel.tsx +140 -0
- package/components/copilot/ChatMessage.tsx +251 -0
- package/components/copilot/ClickableReference.tsx +40 -0
- package/components/copilot/CopilotCornerstoneViewer.tsx +562 -0
- package/components/copilot/CopilotPanel.tsx +311 -0
- package/components/copilot/FindingsList.tsx +75 -0
- package/components/copilot/ViewerPanel.tsx +460 -0
- package/components/copilot/WorkspaceLayout.tsx +398 -0
- package/components/dashboard/AIConfigPanel.tsx +339 -0
- package/components/dashboard/AppearancePanel.tsx +491 -0
- package/components/dashboard/ApprovalModal.tsx +163 -0
- package/components/dashboard/CollaborationPanel.tsx +134 -0
- package/components/dashboard/CopilotConfigPanel.tsx +337 -0
- package/components/dashboard/DicomViewer.tsx +645 -0
- package/components/dashboard/FhirIntegrationPanel.tsx +331 -0
- package/components/dashboard/FullReportOverlay.tsx +269 -0
- package/components/dashboard/ImageViewer.tsx +541 -0
- package/components/dashboard/PatientForm.tsx +597 -0
- package/components/dashboard/RejectionModal.tsx +74 -0
- package/components/dashboard/ReportEditor.tsx +160 -0
- package/components/dashboard/ReportTemplates.tsx +729 -0
- package/components/dashboard/ReportView.tsx +539 -0
- package/components/dashboard/SegmentationConfigPanel.tsx +490 -0
- package/components/dashboard/StudyPlaceholder.tsx +17 -0
- package/components/dashboard/SupabaseIntegrationPanel.tsx +345 -0
- package/components/dashboard/UserManagementPanel.tsx +272 -0
- package/components/layout/ClientLayout.tsx +39 -0
- package/components/layout/Header.tsx +20 -0
- package/components/layout/Sidebar.tsx +119 -0
- package/components/pacs/PacsImageViewerModal.tsx +121 -0
- package/components/pacs/PacsSearchFilters.tsx +117 -0
- package/components/pacs/PacsSeriesViewer.tsx +190 -0
- package/components/pacs/PacsStudyTable.tsx +113 -0
- package/components/patients/patient-card.tsx +117 -0
- package/components/patients/patient-header.tsx +122 -0
- package/components/patients/patient-search.tsx +137 -0
- package/components/patients/patient-timeline.tsx +153 -0
- package/components/settings/ComplianceSettingsPanel.tsx +278 -0
- package/components/settings/SecurityPanel.tsx +418 -0
- package/components/ui/badge.tsx +19 -0
- package/components/ui/basic.tsx +156 -0
- package/db/index.ts +350 -0
- package/db/migrations/0000_odd_quasimodo.sql +117 -0
- package/db/migrations/meta/0000_snapshot.json +778 -0
- package/db/migrations/meta/_journal.json +13 -0
- package/db/schema.ts +239 -0
- package/drizzle.config.ts +10 -0
- package/lib/api.ts +689 -0
- package/lib/auth.ts +22 -0
- package/lib/copilot/action-executor.ts +94 -0
- package/lib/copilot/action-types.ts +72 -0
- package/lib/copilot/coordinate-mapper.ts +84 -0
- package/lib/dicomImageExtractor.ts +103 -0
- package/lib/dicomMetadataParser.ts +111 -0
- package/lib/fhir/client.ts +25 -0
- package/lib/fhir/constants.ts +21 -0
- package/lib/fhir/diagnostic-report.ts +88 -0
- package/lib/fhir/helpers.ts +73 -0
- package/lib/fhir/imaging-study.ts +49 -0
- package/lib/fhir/patient.ts +55 -0
- package/lib/fhir/service-request.ts +85 -0
- package/lib/fhir.ts +6 -0
- package/lib/pacs/dicom-utils.ts +72 -0
- package/lib/pacs/dicomweb.ts +72 -0
- package/lib/pacs/server-utils.ts +37 -0
- package/lib/patients.ts +25 -0
- package/lib/pdfHelper.ts +119 -0
- package/lib/reportHtmlGenerator.ts +581 -0
- package/lib/security/audit.ts +180 -0
- package/lib/security/authz.ts +246 -0
- package/lib/security/phi-redaction.ts +156 -0
- package/lib/security/rate-limit.ts +106 -0
- package/lib/security/secrets.ts +179 -0
- package/lib/supabase.ts +72 -0
- package/lib/utils.ts +6 -0
- package/next.config.ts +35 -0
- package/package.json +76 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/logo.svg +8 -0
- package/public/next.svg +1 -0
- package/public/omnirad-favicon.svg +8 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/tsconfig.json +34 -0
- package/types/copilot-viewer.ts +155 -0
- package/types/copilot.ts +105 -0
- package/types/fhir.ts +21 -0
- package/types/html2pdf.d.ts +20 -0
- package/types/index.ts +139 -0
- 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
|
+
}
|