@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
@@ -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
+ }