@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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { sqlite } from "@/db";
|
|
3
|
+
|
|
4
|
+
// GET /api/copilot/history — get chat history
|
|
5
|
+
export async function GET(req: NextRequest) {
|
|
6
|
+
const { searchParams } = new URL(req.url);
|
|
7
|
+
const sessionId = searchParams.get("sessionId");
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
if (sessionId) {
|
|
11
|
+
// Return all messages for a specific session
|
|
12
|
+
const stmt = sqlite.prepare(
|
|
13
|
+
"SELECT * FROM chat_messages WHERE session_id = ? ORDER BY created_at ASC"
|
|
14
|
+
);
|
|
15
|
+
const messages = stmt.all(sessionId);
|
|
16
|
+
|
|
17
|
+
return NextResponse.json(
|
|
18
|
+
messages.map((m: any) => ({
|
|
19
|
+
id: m.id,
|
|
20
|
+
role: m.role,
|
|
21
|
+
content: m.content,
|
|
22
|
+
viewerActions: m.viewer_actions ? JSON.parse(m.viewer_actions) : [],
|
|
23
|
+
references: m.references_data ? JSON.parse(m.references_data) : [],
|
|
24
|
+
timestamp: m.created_at,
|
|
25
|
+
}))
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Return list of all sessions (grouped)
|
|
30
|
+
const stmt = sqlite.prepare(`
|
|
31
|
+
SELECT
|
|
32
|
+
session_id,
|
|
33
|
+
MAX(patient_id) as patient_id,
|
|
34
|
+
MIN(created_at) as first_message,
|
|
35
|
+
MAX(created_at) as last_message,
|
|
36
|
+
COUNT(*) as message_count
|
|
37
|
+
FROM chat_messages
|
|
38
|
+
GROUP BY session_id
|
|
39
|
+
ORDER BY MAX(created_at) DESC
|
|
40
|
+
LIMIT 50
|
|
41
|
+
`);
|
|
42
|
+
const sessions = stmt.all();
|
|
43
|
+
|
|
44
|
+
// Get the latest message content for each session
|
|
45
|
+
const result = (sessions as any[]).map((s: any) => {
|
|
46
|
+
const lastMsg = sqlite.prepare(
|
|
47
|
+
"SELECT content FROM chat_messages WHERE session_id = ? ORDER BY created_at DESC LIMIT 1"
|
|
48
|
+
).get(s.session_id) as any;
|
|
49
|
+
|
|
50
|
+
// Get patient name if patient_id exists
|
|
51
|
+
let patientName = null;
|
|
52
|
+
if (s.patient_id) {
|
|
53
|
+
const patient = sqlite.prepare(
|
|
54
|
+
"SELECT patient_name FROM patients WHERE id = ?"
|
|
55
|
+
).get(s.patient_id) as any;
|
|
56
|
+
if (patient) patientName = patient.patient_name;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
sessionId: s.session_id,
|
|
61
|
+
patientId: s.patient_id,
|
|
62
|
+
patientName,
|
|
63
|
+
lastMessage: lastMsg?.content || "",
|
|
64
|
+
messageCount: s.message_count,
|
|
65
|
+
createdAt: s.first_message,
|
|
66
|
+
updatedAt: s.last_message,
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return NextResponse.json(result);
|
|
71
|
+
} catch (error: any) {
|
|
72
|
+
console.error("[Copilot] History error:", error);
|
|
73
|
+
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// DELETE /api/copilot/history — clear session history
|
|
78
|
+
export async function DELETE(req: NextRequest) {
|
|
79
|
+
const { searchParams } = new URL(req.url);
|
|
80
|
+
const sessionId = searchParams.get("sessionId");
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
if (sessionId) {
|
|
84
|
+
sqlite.prepare("DELETE FROM chat_messages WHERE session_id = ?").run(sessionId);
|
|
85
|
+
return NextResponse.json({ success: true, message: `Session ${sessionId} cleared.` });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Clear all chat history
|
|
89
|
+
sqlite.exec("DELETE FROM chat_messages");
|
|
90
|
+
return NextResponse.json({ success: true, message: "All chat history cleared." });
|
|
91
|
+
} catch (error: any) {
|
|
92
|
+
console.error("[Copilot] Delete error:", error);
|
|
93
|
+
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { sqlite } from "@/db";
|
|
3
|
+
|
|
4
|
+
// GET /api/copilot/reports — get report data for the viewer panel
|
|
5
|
+
export async function GET(req: NextRequest) {
|
|
6
|
+
const { searchParams } = new URL(req.url);
|
|
7
|
+
const id = searchParams.get("id");
|
|
8
|
+
const patientId = searchParams.get("patientId");
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
if (id) {
|
|
12
|
+
// Get specific report by DB id
|
|
13
|
+
let row = sqlite.prepare("SELECT * FROM reports WHERE id = ?").get(id) as any;
|
|
14
|
+
|
|
15
|
+
// If not found by DB id, search by report_header.report_id in JSON
|
|
16
|
+
if (!row) {
|
|
17
|
+
const allReports = sqlite.prepare("SELECT * FROM reports").all() as any[];
|
|
18
|
+
for (const r of allReports) {
|
|
19
|
+
try {
|
|
20
|
+
const rd = JSON.parse(r.report_data);
|
|
21
|
+
if (rd?.report_header?.report_id === id) {
|
|
22
|
+
row = r;
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
} catch { /* skip */ }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!row) {
|
|
30
|
+
return NextResponse.json({ error: "Report not found" }, { status: 404 });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const reportData = typeof row.report_data === 'string' ? JSON.parse(row.report_data) : row.report_data;
|
|
34
|
+
|
|
35
|
+
return NextResponse.json({
|
|
36
|
+
id: row.id,
|
|
37
|
+
patientId: row.patient_id,
|
|
38
|
+
patientName: row.patient_name,
|
|
39
|
+
modality: row.modality,
|
|
40
|
+
urgency: row.urgency,
|
|
41
|
+
reportStatus: row.report_status,
|
|
42
|
+
reportData,
|
|
43
|
+
imageData: row.image_data || null,
|
|
44
|
+
pacsStudyUid: row.pacs_study_uid,
|
|
45
|
+
createdAt: row.created_at,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (patientId) {
|
|
50
|
+
// Get all reports for a patient
|
|
51
|
+
const rows = sqlite.prepare(
|
|
52
|
+
"SELECT id, patient_name, modality, urgency, report_status, report_data, image_data, created_at FROM reports WHERE patient_id = ? ORDER BY created_at DESC"
|
|
53
|
+
).all(patientId) as any[];
|
|
54
|
+
|
|
55
|
+
const reports = rows.map((row: any) => {
|
|
56
|
+
let reportData: any = {};
|
|
57
|
+
try {
|
|
58
|
+
reportData = JSON.parse(row.report_data);
|
|
59
|
+
} catch { /* skip */ }
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
id: row.id,
|
|
63
|
+
reportId: reportData?.report_header?.report_id || row.id,
|
|
64
|
+
patientName: row.patient_name,
|
|
65
|
+
modality: reportData?.study?.modality || row.modality,
|
|
66
|
+
date: reportData?.report_header?.report_date || row.created_at,
|
|
67
|
+
status: row.report_status,
|
|
68
|
+
urgency: reportData?.urgency || row.urgency,
|
|
69
|
+
hasImages: !!(row.image_data || reportData?.image_data),
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return NextResponse.json(reports);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return NextResponse.json({ error: "Either 'id' or 'patientId' parameter is required" }, { status: 400 });
|
|
77
|
+
} catch (error: any) {
|
|
78
|
+
console.error("[Copilot] Reports error:", error);
|
|
79
|
+
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/// <reference types="fhir" />
|
|
2
|
+
import { NextRequest } from "next/server";
|
|
3
|
+
import { db } from "@/db";
|
|
4
|
+
import { reports, patients } from "@/db/schema";
|
|
5
|
+
import { eq } from "drizzle-orm";
|
|
6
|
+
import {
|
|
7
|
+
toFhirPatient,
|
|
8
|
+
toFhirDiagnosticReport,
|
|
9
|
+
toFhirImagingStudy,
|
|
10
|
+
fhirResponse,
|
|
11
|
+
fhirErrorResponse,
|
|
12
|
+
requireFhirAccess
|
|
13
|
+
} from "@/lib/fhir";
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
export async function GET(
|
|
17
|
+
request: NextRequest,
|
|
18
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
19
|
+
) {
|
|
20
|
+
// 1. Auth check
|
|
21
|
+
const authError = await requireFhirAccess(request, "read");
|
|
22
|
+
if (authError) return authError;
|
|
23
|
+
|
|
24
|
+
// 2. Fetch data
|
|
25
|
+
const { id } = await params;
|
|
26
|
+
const reportRow = db.select().from(reports).where(eq(reports.id, id)).get();
|
|
27
|
+
|
|
28
|
+
if (!reportRow) {
|
|
29
|
+
return fhirErrorResponse("not-found", `DiagnosticReport with id '${id}' not found`, 404);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let reportData;
|
|
33
|
+
try {
|
|
34
|
+
reportData = JSON.parse(reportRow.reportData);
|
|
35
|
+
} catch (e) {
|
|
36
|
+
return fhirErrorResponse("exception", "Failed to parse internal report data", 500);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const patientRow = reportRow.patientId
|
|
40
|
+
? db.select().from(patients).where(eq(patients.id, reportRow.patientId)).get()
|
|
41
|
+
: undefined;
|
|
42
|
+
|
|
43
|
+
// 3. Serialize resources
|
|
44
|
+
const entries: fhir4.BundleEntry[] = [];
|
|
45
|
+
|
|
46
|
+
// Add Patient
|
|
47
|
+
if (patientRow) {
|
|
48
|
+
entries.push({
|
|
49
|
+
fullUrl: `urn:uuid:${patientRow.id}`,
|
|
50
|
+
resource: toFhirPatient(patientRow as any)
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Add DiagnosticReport
|
|
55
|
+
const fhirReport = toFhirDiagnosticReport({
|
|
56
|
+
report: reportRow,
|
|
57
|
+
reportData,
|
|
58
|
+
patient: patientRow as any
|
|
59
|
+
});
|
|
60
|
+
entries.push({
|
|
61
|
+
fullUrl: `urn:uuid:${fhirReport.id}`,
|
|
62
|
+
resource: fhirReport
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Add ImagingStudy if it exists
|
|
66
|
+
if (reportRow.pacsStudyUid) {
|
|
67
|
+
const fhirStudy = toFhirImagingStudy({
|
|
68
|
+
report: reportRow,
|
|
69
|
+
reportData,
|
|
70
|
+
patient: patientRow as any
|
|
71
|
+
});
|
|
72
|
+
entries.push({
|
|
73
|
+
fullUrl: `urn:uuid:${fhirStudy.id}`,
|
|
74
|
+
resource: fhirStudy
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const bundle: fhir4.Bundle = {
|
|
79
|
+
resourceType: "Bundle",
|
|
80
|
+
type: "collection",
|
|
81
|
+
entry: entries
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return fhirResponse(bundle);
|
|
85
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { NextRequest } from "next/server";
|
|
2
|
+
import { db } from "@/db";
|
|
3
|
+
import { reports, patients } from "@/db/schema";
|
|
4
|
+
import { eq } from "drizzle-orm";
|
|
5
|
+
import { toFhirDiagnosticReport, fhirResponse, fhirErrorResponse, requireFhirAccess } from "@/lib/fhir";
|
|
6
|
+
|
|
7
|
+
export async function GET(
|
|
8
|
+
request: NextRequest,
|
|
9
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
10
|
+
) {
|
|
11
|
+
// 1. Auth check
|
|
12
|
+
const authError = await requireFhirAccess(request, "read");
|
|
13
|
+
if (authError) return authError;
|
|
14
|
+
|
|
15
|
+
// 2. Fetch data
|
|
16
|
+
const { id } = await params;
|
|
17
|
+
const reportRow = db.select().from(reports).where(eq(reports.id, id)).get();
|
|
18
|
+
|
|
19
|
+
if (!reportRow) {
|
|
20
|
+
return fhirErrorResponse("not-found", `DiagnosticReport with id '${id}' not found`, 404);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let reportData;
|
|
24
|
+
try {
|
|
25
|
+
reportData = JSON.parse(reportRow.reportData);
|
|
26
|
+
} catch (e) {
|
|
27
|
+
return fhirErrorResponse("exception", "Failed to parse internal report data", 500);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const patientRow = reportRow.patientId
|
|
31
|
+
? db.select().from(patients).where(eq(patients.id, reportRow.patientId)).get()
|
|
32
|
+
: undefined;
|
|
33
|
+
|
|
34
|
+
// Optional PDF generation handling could go here
|
|
35
|
+
// const includePdf = request.nextUrl.searchParams.get("includePdf") === "true";
|
|
36
|
+
|
|
37
|
+
// 3. Serialize and return
|
|
38
|
+
const resource = toFhirDiagnosticReport({
|
|
39
|
+
report: reportRow,
|
|
40
|
+
reportData,
|
|
41
|
+
patient: patientRow as any
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return fhirResponse(resource);
|
|
45
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { NextRequest } from "next/server";
|
|
2
|
+
import { db } from "@/db";
|
|
3
|
+
import { reports, patients } from "@/db/schema";
|
|
4
|
+
import { eq, or } from "drizzle-orm";
|
|
5
|
+
import { toFhirImagingStudy, fhirResponse, fhirErrorResponse, requireFhirAccess, sanitizeId } from "@/lib/fhir";
|
|
6
|
+
|
|
7
|
+
export async function GET(
|
|
8
|
+
request: NextRequest,
|
|
9
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
10
|
+
) {
|
|
11
|
+
// 1. Auth check
|
|
12
|
+
const authError = await requireFhirAccess(request, "read");
|
|
13
|
+
if (authError) return authError;
|
|
14
|
+
|
|
15
|
+
// 2. Fetch data
|
|
16
|
+
const { id } = await params;
|
|
17
|
+
|
|
18
|
+
// We try to find the report by its sanitized PACS UID OR its internal ID
|
|
19
|
+
// Since we don't have a direct index on sanitized UID, we might just load by ID
|
|
20
|
+
// or by exact pacsStudyUid if they provided the raw one
|
|
21
|
+
const reportRow = db.select().from(reports)
|
|
22
|
+
.where(or(
|
|
23
|
+
eq(reports.id, id),
|
|
24
|
+
eq(reports.pacsStudyUid, id) // Fallback in case they used the raw DICOM UID
|
|
25
|
+
)).get();
|
|
26
|
+
|
|
27
|
+
if (!reportRow) {
|
|
28
|
+
// As a fallback, we could query all reports and sanitize their UIDs, but for performance,
|
|
29
|
+
// we'll stick to ID or raw UID for now. If they want to use sanitized UID, they should
|
|
30
|
+
// look it up via DiagnosticReport.imagingStudy.reference first.
|
|
31
|
+
return fhirErrorResponse("not-found", `ImagingStudy with id '${id}' not found`, 404);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!reportRow.pacsStudyUid) {
|
|
35
|
+
return fhirErrorResponse("not-supported", `Report '${reportRow.id}' does not have associated PACS metadata`, 404);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let reportData;
|
|
39
|
+
try {
|
|
40
|
+
reportData = JSON.parse(reportRow.reportData);
|
|
41
|
+
} catch (e) {
|
|
42
|
+
return fhirErrorResponse("exception", "Failed to parse internal report data", 500);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const patientRow = reportRow.patientId
|
|
46
|
+
? db.select().from(patients).where(eq(patients.id, reportRow.patientId)).get()
|
|
47
|
+
: undefined;
|
|
48
|
+
|
|
49
|
+
// 3. Serialize and return
|
|
50
|
+
const resource = toFhirImagingStudy({
|
|
51
|
+
report: reportRow,
|
|
52
|
+
reportData,
|
|
53
|
+
patient: patientRow as any
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return fhirResponse(resource);
|
|
57
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { NextRequest } from "next/server";
|
|
2
|
+
import { db } from "@/db";
|
|
3
|
+
import { patients } from "@/db/schema";
|
|
4
|
+
import { eq } from "drizzle-orm";
|
|
5
|
+
import { toFhirPatient, fhirResponse, fhirErrorResponse, requireFhirAccess } from "@/lib/fhir";
|
|
6
|
+
|
|
7
|
+
export async function GET(
|
|
8
|
+
request: NextRequest,
|
|
9
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
10
|
+
) {
|
|
11
|
+
// 1. Auth check
|
|
12
|
+
const authError = await requireFhirAccess(request, "read");
|
|
13
|
+
if (authError) return authError;
|
|
14
|
+
|
|
15
|
+
// 2. Fetch data
|
|
16
|
+
const { id } = await params;
|
|
17
|
+
const patientRow = db.select().from(patients).where(eq(patients.id, id)).get();
|
|
18
|
+
|
|
19
|
+
if (!patientRow) {
|
|
20
|
+
return fhirErrorResponse("not-found", `Patient with id '${id}' not found`, 404);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 3. Serialize and return
|
|
24
|
+
const resource = toFhirPatient(patientRow as any);
|
|
25
|
+
return fhirResponse(resource);
|
|
26
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/// <reference types="fhir" />
|
|
2
|
+
import { NextRequest } from "next/server";
|
|
3
|
+
import { db } from "@/db";
|
|
4
|
+
import { patients, worklistOrders } from "@/db/schema";
|
|
5
|
+
import { eq } from "drizzle-orm";
|
|
6
|
+
import {
|
|
7
|
+
parseInboundServiceRequest,
|
|
8
|
+
toFhirServiceRequest,
|
|
9
|
+
fhirResponse,
|
|
10
|
+
fhirErrorResponse,
|
|
11
|
+
requireFhirAccess
|
|
12
|
+
} from "@/lib/fhir";
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
export async function POST(request: NextRequest) {
|
|
16
|
+
// 1. Auth check
|
|
17
|
+
const authError = await requireFhirAccess(request, "write");
|
|
18
|
+
if (authError) return authError;
|
|
19
|
+
|
|
20
|
+
// 2. Validate content type
|
|
21
|
+
const contentType = request.headers.get("content-type");
|
|
22
|
+
if (!contentType?.includes("application/fhir+json")) {
|
|
23
|
+
return fhirErrorResponse("invalid", "Content-Type must be application/fhir+json", 415);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const body = await request.json() as fhir4.ServiceRequest;
|
|
28
|
+
|
|
29
|
+
if (body.resourceType !== "ServiceRequest") {
|
|
30
|
+
return fhirErrorResponse("invalid", `Expected ServiceRequest, got ${body.resourceType}`, 400);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 3. Parse and map to OmniRad model
|
|
34
|
+
const orderInput = parseInboundServiceRequest(body);
|
|
35
|
+
|
|
36
|
+
// Ensure patient exists
|
|
37
|
+
let patientId = orderInput.patientId;
|
|
38
|
+
|
|
39
|
+
if (orderInput.patientIdentifier && !patientId) {
|
|
40
|
+
// Try to find patient by identifier
|
|
41
|
+
const existing = db.select().from(patients).where(eq(patients.patientIdNumber, orderInput.patientIdentifier)).get();
|
|
42
|
+
if (existing) {
|
|
43
|
+
patientId = existing.id;
|
|
44
|
+
} else {
|
|
45
|
+
// We could auto-create a patient stub here if we had all the details,
|
|
46
|
+
// but usually the EMR sends Patient first or we match by demographics.
|
|
47
|
+
// For MVP, we'll store the order without a linked patient ID, but keeping the name/identifier.
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const newId = `order_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
52
|
+
|
|
53
|
+
// 4. Store in database
|
|
54
|
+
const newOrder = {
|
|
55
|
+
id: newId,
|
|
56
|
+
sourceSystem: "FHIR",
|
|
57
|
+
fhirServiceRequestId: body.id || null,
|
|
58
|
+
patientId: patientId || null,
|
|
59
|
+
patientName: orderInput.patientName,
|
|
60
|
+
patientIdentifier: orderInput.patientIdentifier,
|
|
61
|
+
status: orderInput.status,
|
|
62
|
+
intent: orderInput.intent,
|
|
63
|
+
priority: orderInput.priority,
|
|
64
|
+
urgency: orderInput.urgency,
|
|
65
|
+
modality: orderInput.modality || null,
|
|
66
|
+
requestedProcedure: orderInput.requestedProcedure,
|
|
67
|
+
reason: orderInput.reason || null,
|
|
68
|
+
authoredOn: orderInput.authoredOn || new Date().toISOString(),
|
|
69
|
+
requesterDisplay: orderInput.requesterDisplay || null,
|
|
70
|
+
rawFhir: JSON.stringify(body),
|
|
71
|
+
createdAt: new Date().toISOString(),
|
|
72
|
+
updatedAt: new Date().toISOString()
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
db.insert(worklistOrders).values(newOrder).run();
|
|
76
|
+
|
|
77
|
+
// 5. Return success with the stored resource representation
|
|
78
|
+
const storedResource = toFhirServiceRequest(newOrder as any);
|
|
79
|
+
return fhirResponse(storedResource, 201);
|
|
80
|
+
|
|
81
|
+
} catch (e: any) {
|
|
82
|
+
console.error("[FHIR ServiceRequest] Error processing inbound order:", e);
|
|
83
|
+
return fhirErrorResponse("exception", `Failed to process ServiceRequest: ${e.message}`, 400);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { db } from "@/db";
|
|
3
|
+
import { fhirIntegrationConfig } from "@/db/schema";
|
|
4
|
+
import { eq } from "drizzle-orm";
|
|
5
|
+
import { requireUser, requirePermission, handleAuthError } from "@/lib/security/authz";
|
|
6
|
+
import { maskSecret } from "@/lib/security/secrets";
|
|
7
|
+
import { safeError } from "@/lib/security/phi-redaction";
|
|
8
|
+
import { auditSuccess, auditEventFromContext } from "@/lib/security/audit";
|
|
9
|
+
|
|
10
|
+
export async function GET(request: NextRequest) {
|
|
11
|
+
try {
|
|
12
|
+
const ctx = await requireUser(request);
|
|
13
|
+
requirePermission(ctx, "settings.read");
|
|
14
|
+
|
|
15
|
+
const row = db.select().from(fhirIntegrationConfig).where(eq(fhirIntegrationConfig.id, 1)).get();
|
|
16
|
+
if (!row) {
|
|
17
|
+
return NextResponse.json({
|
|
18
|
+
enabled: false,
|
|
19
|
+
publicBaseUrl: "",
|
|
20
|
+
authMode: "bearer_token",
|
|
21
|
+
inboundServiceRequestEnabled: false,
|
|
22
|
+
outboundReadEnabled: true,
|
|
23
|
+
externalFhirBaseUrl: "",
|
|
24
|
+
externalFhirAuthType: "none",
|
|
25
|
+
externalFhirClientId: "",
|
|
26
|
+
externalFhirClientSecret: "",
|
|
27
|
+
hasClientSecret: false,
|
|
28
|
+
externalFhirBearerToken: "",
|
|
29
|
+
hasBearerToken: false,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return NextResponse.json({
|
|
33
|
+
enabled: row.enabled ?? false,
|
|
34
|
+
publicBaseUrl: row.publicBaseUrl || "",
|
|
35
|
+
authMode: row.authMode || "bearer_token",
|
|
36
|
+
inboundServiceRequestEnabled: row.inboundServiceRequestEnabled ?? false,
|
|
37
|
+
outboundReadEnabled: row.outboundReadEnabled ?? true,
|
|
38
|
+
externalFhirBaseUrl: row.externalFhirBaseUrl || "",
|
|
39
|
+
externalFhirAuthType: row.externalFhirAuthType || "none",
|
|
40
|
+
externalFhirClientId: row.externalFhirClientId || "",
|
|
41
|
+
externalFhirClientSecret: maskSecret(row.externalFhirClientSecret),
|
|
42
|
+
hasClientSecret: !!(row.externalFhirClientSecret && row.externalFhirClientSecret.length > 0),
|
|
43
|
+
externalFhirBearerToken: maskSecret(row.externalFhirBearerToken),
|
|
44
|
+
hasBearerToken: !!(row.externalFhirBearerToken && row.externalFhirBearerToken.length > 0),
|
|
45
|
+
});
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if ((error as any)?.statusCode) return handleAuthError(error);
|
|
48
|
+
safeError("Error reading FHIR configuration:", error);
|
|
49
|
+
return NextResponse.json({ error: "Failed to read FHIR configuration" }, { status: 500 });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function POST(request: NextRequest) {
|
|
54
|
+
try {
|
|
55
|
+
const ctx = await requireUser(request);
|
|
56
|
+
requirePermission(ctx, "settings.write");
|
|
57
|
+
|
|
58
|
+
const data = await request.json();
|
|
59
|
+
const exists = db.select().from(fhirIntegrationConfig).where(eq(fhirIntegrationConfig.id, 1)).get();
|
|
60
|
+
const now = new Date().toISOString();
|
|
61
|
+
|
|
62
|
+
const updateData: Record<string, any> = {
|
|
63
|
+
enabled: data.enabled ?? false,
|
|
64
|
+
publicBaseUrl: data.publicBaseUrl || "",
|
|
65
|
+
authMode: data.authMode || "bearer_token",
|
|
66
|
+
inboundServiceRequestEnabled: data.inboundServiceRequestEnabled ?? false,
|
|
67
|
+
outboundReadEnabled: data.outboundReadEnabled ?? true,
|
|
68
|
+
externalFhirBaseUrl: data.externalFhirBaseUrl || "",
|
|
69
|
+
externalFhirAuthType: data.externalFhirAuthType || "none",
|
|
70
|
+
externalFhirClientId: data.externalFhirClientId || "",
|
|
71
|
+
updatedAt: now,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Only update secrets if new non-masked values provided
|
|
75
|
+
if (data.externalFhirClientSecret && !data.externalFhirClientSecret.startsWith("********")) {
|
|
76
|
+
updateData.externalFhirClientSecret = data.externalFhirClientSecret;
|
|
77
|
+
}
|
|
78
|
+
if (data.externalFhirBearerToken && !data.externalFhirBearerToken.startsWith("********")) {
|
|
79
|
+
updateData.externalFhirBearerToken = data.externalFhirBearerToken;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (exists) {
|
|
83
|
+
db.update(fhirIntegrationConfig).set(updateData).where(eq(fhirIntegrationConfig.id, 1)).run();
|
|
84
|
+
} else {
|
|
85
|
+
db.insert(fhirIntegrationConfig).values({
|
|
86
|
+
id: 1,
|
|
87
|
+
...updateData,
|
|
88
|
+
createdAt: now,
|
|
89
|
+
}).run();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
await auditSuccess(auditEventFromContext(ctx, "settings.update", "fhir", {
|
|
93
|
+
metadata: { method: "POST", route: "/api/fhir/config" },
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
return NextResponse.json({ success: true });
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if ((error as any)?.statusCode) return handleAuthError(error);
|
|
99
|
+
safeError("Error saving FHIR configuration:", error);
|
|
100
|
+
return NextResponse.json({ error: "Failed to save FHIR configuration" }, { status: 500 });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { createFhirClient } from "@/lib/fhir/client";
|
|
3
|
+
import { requireUser, requirePermission, handleAuthError } from "@/lib/security/authz";
|
|
4
|
+
import { safeError } from "@/lib/security/phi-redaction";
|
|
5
|
+
|
|
6
|
+
export async function POST(request: NextRequest) {
|
|
7
|
+
try {
|
|
8
|
+
const ctx = await requireUser(request);
|
|
9
|
+
requirePermission(ctx, "settings.write");
|
|
10
|
+
|
|
11
|
+
const body = await request.json();
|
|
12
|
+
const {
|
|
13
|
+
externalFhirBaseUrl,
|
|
14
|
+
externalFhirAuthType,
|
|
15
|
+
externalFhirBearerToken,
|
|
16
|
+
externalFhirClientId,
|
|
17
|
+
externalFhirClientSecret
|
|
18
|
+
} = body;
|
|
19
|
+
|
|
20
|
+
if (!externalFhirBaseUrl) {
|
|
21
|
+
return NextResponse.json({ success: false, error: "FHIR Base URL is required." });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const client = createFhirClient(
|
|
25
|
+
externalFhirBaseUrl,
|
|
26
|
+
externalFhirAuthType,
|
|
27
|
+
externalFhirBearerToken,
|
|
28
|
+
externalFhirClientId,
|
|
29
|
+
externalFhirClientSecret
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// Attempt to fetch capability statement (metadata)
|
|
33
|
+
const capabilityStatement = await client.capabilityStatement();
|
|
34
|
+
|
|
35
|
+
return NextResponse.json({
|
|
36
|
+
success: true,
|
|
37
|
+
fhirVersion: (capabilityStatement as any)?.fhirVersion || "Unknown",
|
|
38
|
+
softwareName: (capabilityStatement as any)?.software?.name || (capabilityStatement as any)?.publisher || "Unknown FHIR Server"
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
} catch (error: any) {
|
|
42
|
+
if (error?.statusCode) return handleAuthError(error);
|
|
43
|
+
safeError("FHIR Test Connection Error:", error);
|
|
44
|
+
return NextResponse.json({
|
|
45
|
+
success: false,
|
|
46
|
+
error: error.message || "Failed to connect to the FHIR server. Please check your credentials and URL."
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/// <reference types="fhir" />
|
|
2
|
+
import { NextRequest } from "next/server";
|
|
3
|
+
import { fhirResponse } from "@/lib/fhir";
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
export async function GET(request: NextRequest) {
|
|
7
|
+
const origin = request.nextUrl.origin;
|
|
8
|
+
|
|
9
|
+
const capabilityStatement: fhir4.CapabilityStatement = {
|
|
10
|
+
resourceType: "CapabilityStatement",
|
|
11
|
+
status: "active",
|
|
12
|
+
date: new Date().toISOString(),
|
|
13
|
+
publisher: "OmniRad",
|
|
14
|
+
kind: "instance",
|
|
15
|
+
software: {
|
|
16
|
+
name: "OmniRad FHIR Integration",
|
|
17
|
+
version: "1.0.0"
|
|
18
|
+
},
|
|
19
|
+
implementation: {
|
|
20
|
+
description: "OmniRad Radiology Information System",
|
|
21
|
+
url: `${origin}/api/fhir`
|
|
22
|
+
},
|
|
23
|
+
fhirVersion: "4.0.1",
|
|
24
|
+
format: ["application/fhir+json"],
|
|
25
|
+
rest: [
|
|
26
|
+
{
|
|
27
|
+
mode: "server",
|
|
28
|
+
resource: [
|
|
29
|
+
{
|
|
30
|
+
type: "Patient",
|
|
31
|
+
interaction: [{ code: "read" }]
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
type: "DiagnosticReport",
|
|
35
|
+
interaction: [{ code: "read" }]
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
type: "ImagingStudy",
|
|
39
|
+
interaction: [{ code: "read" }]
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
type: "ServiceRequest",
|
|
43
|
+
interaction: [{ code: "create" }]
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return fhirResponse(capabilityStatement);
|
|
51
|
+
}
|