@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,108 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db, sqlite } from "@/db";
3
+ import { patients, reports, patientPrivacyControls } from "@/db/schema";
4
+ import { eq } from "drizzle-orm";
5
+ import { requireUser, requirePermission, handleAuthError } from "@/lib/security/authz";
6
+ import { safeError } from "@/lib/security/phi-redaction";
7
+ import { auditSuccess, auditEventFromContext } from "@/lib/security/audit";
8
+ import { randomUUID } from "crypto";
9
+
10
+ // POST /api/compliance/export/patient/[id] — GDPR Article 20: Data Portability
11
+ export async function POST(
12
+ request: NextRequest,
13
+ { params }: { params: Promise<{ id: string }> }
14
+ ) {
15
+ try {
16
+ const ctx = await requireUser(request);
17
+ requirePermission(ctx, "compliance.manage");
18
+
19
+ const { id } = await params;
20
+
21
+ // Get patient data
22
+ const patient = db.select().from(patients).where(eq(patients.id, id)).limit(1).all();
23
+ if (patient.length === 0) {
24
+ return NextResponse.json({ error: "Patient not found" }, { status: 404 });
25
+ }
26
+
27
+ // Get all reports for this patient
28
+ const patientReports = db.select({
29
+ id: reports.id,
30
+ modality: reports.modality,
31
+ urgency: reports.urgency,
32
+ reportStatus: reports.reportStatus,
33
+ reportData: reports.reportData,
34
+ createdAt: reports.createdAt,
35
+ }).from(reports).where(eq(reports.patientId, id)).all();
36
+
37
+ const parsedReports = patientReports.map(r => {
38
+ let reportData: any = {};
39
+ try { reportData = JSON.parse(r.reportData); } catch {}
40
+ // Exclude image_data from exports to keep size manageable
41
+ delete reportData.image_data;
42
+ delete reportData.images_data;
43
+ return {
44
+ id: r.id,
45
+ modality: r.modality,
46
+ urgency: r.urgency,
47
+ reportStatus: r.reportStatus,
48
+ reportData,
49
+ createdAt: r.createdAt,
50
+ };
51
+ });
52
+
53
+ // Get privacy controls
54
+ const privacyCtrl = sqlite.prepare(
55
+ "SELECT * FROM patient_privacy_controls WHERE patient_id = ?"
56
+ ).get(id) as any;
57
+
58
+ // Build export package
59
+ const exportData = {
60
+ exportVersion: "1.0",
61
+ exportedAt: new Date().toISOString(),
62
+ exportedBy: ctx.fullName,
63
+ patient: {
64
+ id: patient[0].id,
65
+ name: patient[0].patientName,
66
+ idNumber: patient[0].patientIdNumber,
67
+ dateOfBirth: patient[0].dob,
68
+ age: patient[0].age,
69
+ gender: patient[0].gender,
70
+ contactInfo: patient[0].contactInfo,
71
+ notes: patient[0].notes,
72
+ createdAt: patient[0].createdAt,
73
+ },
74
+ reports: parsedReports,
75
+ privacyControls: privacyCtrl ? {
76
+ restriction: privacyCtrl.restriction,
77
+ consentStatus: privacyCtrl.consent_status,
78
+ } : null,
79
+ _meta: {
80
+ format: "OmniRad GDPR Export",
81
+ recordCount: parsedReports.length,
82
+ },
83
+ };
84
+
85
+ // Update last exported timestamp
86
+ if (privacyCtrl) {
87
+ sqlite.prepare(
88
+ "UPDATE patient_privacy_controls SET last_exported_at = ?, last_exported_by = ?, updated_at = ? WHERE patient_id = ?"
89
+ ).run(new Date().toISOString(), ctx.userId, new Date().toISOString(), id);
90
+ } else {
91
+ sqlite.prepare(
92
+ "INSERT INTO patient_privacy_controls (id, patient_id, last_exported_at, last_exported_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
93
+ ).run(randomUUID(), id, new Date().toISOString(), ctx.userId, new Date().toISOString(), new Date().toISOString());
94
+ }
95
+
96
+ await auditSuccess(auditEventFromContext(ctx, "patient.export", "patient", {
97
+ resourceId: id,
98
+ patientId: id,
99
+ metadata: { format: "json", count: parsedReports.length },
100
+ }));
101
+
102
+ return NextResponse.json(exportData);
103
+ } catch (error) {
104
+ if ((error as any)?.statusCode) return handleAuthError(error);
105
+ safeError("Error exporting patient data:", error);
106
+ return NextResponse.json({ error: "Failed to export patient data" }, { status: 500 });
107
+ }
108
+ }
@@ -0,0 +1,59 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db, sqlite } from "@/db";
3
+ import { patients } from "@/db/schema";
4
+ import { eq } from "drizzle-orm";
5
+ import { requireUser, requirePermission, handleAuthError } from "@/lib/security/authz";
6
+ import { safeError } from "@/lib/security/phi-redaction";
7
+ import { auditSuccess, auditEventFromContext } from "@/lib/security/audit";
8
+ import { randomUUID } from "crypto";
9
+
10
+ // POST /api/compliance/restrict/patient/[id] — GDPR Article 18: Right to Restriction
11
+ export async function POST(
12
+ request: NextRequest,
13
+ { params }: { params: Promise<{ id: string }> }
14
+ ) {
15
+ try {
16
+ const ctx = await requireUser(request);
17
+ requirePermission(ctx, "compliance.manage");
18
+
19
+ const { id } = await params;
20
+ const body = await request.json();
21
+ const { restriction, reason } = body; // restriction: "restricted" | "none"
22
+
23
+ // Validate patient exists
24
+ const patient = await db.select().from(patients).where(eq(patients.id, id)).limit(1);
25
+ if (patient.length === 0) {
26
+ return NextResponse.json({ error: "Patient not found" }, { status: 404 });
27
+ }
28
+
29
+ const now = new Date().toISOString();
30
+ const existing = sqlite.prepare(
31
+ "SELECT id FROM patient_privacy_controls WHERE patient_id = ?"
32
+ ).get(id) as any;
33
+
34
+ if (existing) {
35
+ sqlite.prepare(`
36
+ UPDATE patient_privacy_controls
37
+ SET restriction = ?, restricted_by = ?, restricted_at = ?, restriction_reason = ?, updated_at = ?
38
+ WHERE patient_id = ?
39
+ `).run(restriction || "restricted", ctx.userId, now, reason || null, now, id);
40
+ } else {
41
+ sqlite.prepare(`
42
+ INSERT INTO patient_privacy_controls (id, patient_id, restriction, restricted_by, restricted_at, restriction_reason, created_at, updated_at)
43
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
44
+ `).run(randomUUID(), id, restriction || "restricted", ctx.userId, now, reason || null, now, now);
45
+ }
46
+
47
+ await auditSuccess(auditEventFromContext(ctx, "patient.restrict", "patient", {
48
+ resourceId: id,
49
+ patientId: id,
50
+ metadata: { restriction: restriction || "restricted" },
51
+ }));
52
+
53
+ return NextResponse.json({ success: true });
54
+ } catch (error) {
55
+ if ((error as any)?.statusCode) return handleAuthError(error);
56
+ safeError("Error restricting patient:", error);
57
+ return NextResponse.json({ error: "Failed to restrict patient" }, { status: 500 });
58
+ }
59
+ }
@@ -0,0 +1,93 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db } from "@/db";
3
+ import { complianceSettings } from "@/db/schema";
4
+ import { eq } from "drizzle-orm";
5
+ import { requireUser, requirePermission, handleAuthError } from "@/lib/security/authz";
6
+ import { safeError } from "@/lib/security/phi-redaction";
7
+ import { auditSuccess, auditEventFromContext } from "@/lib/security/audit";
8
+
9
+ // GET /api/compliance/settings — Get compliance settings
10
+ export async function GET(request: NextRequest) {
11
+ try {
12
+ const ctx = await requireUser(request);
13
+ requirePermission(ctx, "compliance.manage");
14
+
15
+ const row = db.select().from(complianceSettings).where(eq(complianceSettings.id, 1)).get();
16
+
17
+ if (!row) {
18
+ // Return defaults
19
+ return NextResponse.json({
20
+ dataRetentionDays: 2555,
21
+ auditRetentionDays: 2555,
22
+ sessionTimeoutMinutes: 15,
23
+ idleTimeoutMinutes: 30,
24
+ enableGdprExport: true,
25
+ enableGdprAnonymize: true,
26
+ enableGdprRestriction: true,
27
+ legalBasis: "legitimate_interest",
28
+ privacyPolicyUrl: "",
29
+ dpoContactEmail: "",
30
+ });
31
+ }
32
+
33
+ return NextResponse.json({
34
+ dataRetentionDays: row.dataRetentionDays ?? 2555,
35
+ auditRetentionDays: row.auditRetentionDays ?? 2555,
36
+ sessionTimeoutMinutes: row.sessionTimeoutMinutes ?? 15,
37
+ idleTimeoutMinutes: row.idleTimeoutMinutes ?? 30,
38
+ enableGdprExport: !!(row.enableGdprExport ?? 1),
39
+ enableGdprAnonymize: !!(row.enableGdprAnonymize ?? 1),
40
+ enableGdprRestriction: !!(row.enableGdprRestriction ?? 1),
41
+ legalBasis: row.legalBasis || "legitimate_interest",
42
+ privacyPolicyUrl: row.privacyPolicyUrl || "",
43
+ dpoContactEmail: row.dpoContactEmail || "",
44
+ });
45
+ } catch (error) {
46
+ if ((error as any)?.statusCode) return handleAuthError(error);
47
+ safeError("Error fetching compliance settings:", error);
48
+ return NextResponse.json({ error: "Failed to fetch compliance settings" }, { status: 500 });
49
+ }
50
+ }
51
+
52
+ // PUT /api/compliance/settings — Update compliance settings
53
+ export async function PUT(request: NextRequest) {
54
+ try {
55
+ const ctx = await requireUser(request);
56
+ requirePermission(ctx, "compliance.manage");
57
+
58
+ const data = await request.json();
59
+ const now = new Date().toISOString();
60
+
61
+ const exists = db.select().from(complianceSettings).where(eq(complianceSettings.id, 1)).get();
62
+
63
+ const values = {
64
+ dataRetentionDays: data.dataRetentionDays ?? 2555,
65
+ auditRetentionDays: data.auditRetentionDays ?? 2555,
66
+ sessionTimeoutMinutes: data.sessionTimeoutMinutes ?? 15,
67
+ idleTimeoutMinutes: data.idleTimeoutMinutes ?? 30,
68
+ enableGdprExport: !!data.enableGdprExport,
69
+ enableGdprAnonymize: !!data.enableGdprAnonymize,
70
+ enableGdprRestriction: !!data.enableGdprRestriction,
71
+ legalBasis: data.legalBasis || "legitimate_interest",
72
+ privacyPolicyUrl: data.privacyPolicyUrl || null,
73
+ dpoContactEmail: data.dpoContactEmail || null,
74
+ updatedAt: now,
75
+ };
76
+
77
+ if (exists) {
78
+ db.update(complianceSettings).set(values).where(eq(complianceSettings.id, 1)).run();
79
+ } else {
80
+ db.insert(complianceSettings).values({ id: 1, ...values }).run();
81
+ }
82
+
83
+ await auditSuccess(auditEventFromContext(ctx, "compliance.settings.update", "config", {
84
+ metadata: { method: "PUT", route: "/api/compliance/settings" },
85
+ }));
86
+
87
+ return NextResponse.json({ success: true });
88
+ } catch (error) {
89
+ if ((error as any)?.statusCode) return handleAuthError(error);
90
+ safeError("Error updating compliance settings:", error);
91
+ return NextResponse.json({ error: "Failed to update compliance settings" }, { status: 500 });
92
+ }
93
+ }
@@ -0,0 +1,94 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { requireUser, requirePermission, handleAuthError } from "@/lib/security/authz";
3
+ import { safeError } from "@/lib/security/phi-redaction";
4
+ import { auditSuccess, auditEventFromContext } from "@/lib/security/audit";
5
+
6
+ // POST /api/copilot/annotate — proxy to Python backend for structured annotation
7
+ export async function POST(req: NextRequest) {
8
+ try {
9
+ const ctx = await requireUser(req);
10
+ requirePermission(ctx, "copilot.use");
11
+
12
+ const body = await req.json();
13
+ const {
14
+ message,
15
+ session_id,
16
+ patient_id,
17
+ report_id,
18
+ study_uid,
19
+ series_uid,
20
+ current_slice,
21
+ total_slices,
22
+ modality,
23
+ report_text,
24
+ viewport_image,
25
+ } = body;
26
+
27
+ if (!message) {
28
+ return NextResponse.json({ error: "Message is required" }, { status: 400 });
29
+ }
30
+
31
+ await auditSuccess(auditEventFromContext(ctx, "segmentation.request", "ai", {
32
+ patientId: patient_id || undefined,
33
+ metadata: { modality: modality || undefined },
34
+ }));
35
+
36
+ // Proxy to Python backend
37
+ const pythonUrl = "http://localhost:8001/copilot/annotate";
38
+ const pythonResponse = await fetch(pythonUrl, {
39
+ method: "POST",
40
+ headers: { "Content-Type": "application/json" },
41
+ body: JSON.stringify({
42
+ message,
43
+ session_id: session_id || null,
44
+ patient_id: patient_id || null,
45
+ report_id: report_id || null,
46
+ study_uid: study_uid || null,
47
+ series_uid: series_uid || null,
48
+ current_slice: current_slice ?? null,
49
+ total_slices: total_slices ?? null,
50
+ modality: modality || null,
51
+ report_text: report_text || null,
52
+ viewport_image: viewport_image || null,
53
+ }),
54
+ });
55
+
56
+ if (!pythonResponse.ok) {
57
+ safeError("Copilot annotate backend error", { status: pythonResponse.status });
58
+ return NextResponse.json(
59
+ {
60
+ reply: "⚠️ Could not reach the AI Copilot service for annotation.",
61
+ viewer_actions: [],
62
+ findings_summary: [],
63
+ },
64
+ { status: 200 }
65
+ );
66
+ }
67
+
68
+ const result = await pythonResponse.json();
69
+
70
+ return NextResponse.json({
71
+ reply: result.reply || "",
72
+ viewer_actions: result.viewer_actions || [],
73
+ findings_summary: result.findings_summary || [],
74
+ references: result.references || [],
75
+ });
76
+ } catch (error: any) {
77
+ if ((error as any)?.statusCode) return handleAuthError(error);
78
+
79
+ if (error?.cause?.code === "ECONNREFUSED" || error?.message?.includes("ECONNREFUSED")) {
80
+ return NextResponse.json({
81
+ reply: "⚠️ The AI Copilot backend is not running.",
82
+ viewer_actions: [],
83
+ findings_summary: [],
84
+ });
85
+ }
86
+
87
+ safeError("Copilot annotate error:", error);
88
+ return NextResponse.json({
89
+ reply: "⚠️ An unexpected error occurred.",
90
+ viewer_actions: [],
91
+ findings_summary: [],
92
+ });
93
+ }
94
+ }
@@ -0,0 +1,238 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db, sqlite } from "@/db";
3
+ import { requireUser, requirePermission, handleAuthError } from "@/lib/security/authz";
4
+ import { safeError } from "@/lib/security/phi-redaction";
5
+ import { auditSuccess, auditEventFromContext } from "@/lib/security/audit";
6
+
7
+ // POST /api/copilot/chat — proxy to Python backend + save messages
8
+ // Supports SSE streaming when 'x-stream: true' header is present
9
+ export async function POST(req: NextRequest) {
10
+ try {
11
+ const ctx = await requireUser(req);
12
+ requirePermission(ctx, "copilot.use");
13
+
14
+ const body = await req.json();
15
+ const { message, patientContext, chatHistory, sessionId, study_context } = body;
16
+ const wantsStream = body.stream === true;
17
+
18
+ if (!message) {
19
+ return NextResponse.json({ error: "Message is required" }, { status: 400 });
20
+ }
21
+
22
+ const activeSessionId = sessionId || `session_${Date.now()}`;
23
+
24
+ // Save user message to SQLite
25
+ try {
26
+ const insertStmt = sqlite.prepare(
27
+ "INSERT INTO chat_messages (session_id, role, content, patient_id, created_at) VALUES (?, 'user', ?, ?, ?)"
28
+ );
29
+ insertStmt.run(
30
+ activeSessionId,
31
+ message,
32
+ patientContext?.patientId || null,
33
+ new Date().toISOString()
34
+ );
35
+ } catch (dbErr) {
36
+ safeError("Error saving user message:", dbErr);
37
+ }
38
+
39
+ // Audit the copilot message (without storing content)
40
+ await auditSuccess(auditEventFromContext(ctx, "copilot.message", "ai", {
41
+ patientId: patientContext?.patientId || undefined,
42
+ metadata: { purpose: "copilot" },
43
+ }));
44
+
45
+ // Sanitize incoming history
46
+ const sanitizedHistory = (chatHistory || []).map((msg: any) => ({
47
+ role: msg.role,
48
+ content: typeof msg.content === "string"
49
+ ? msg.content
50
+ : Array.isArray(msg.content)
51
+ ? msg.content.map((c: any) => c.text || JSON.stringify(c)).join("\n")
52
+ : JSON.stringify(msg.content)
53
+ }));
54
+
55
+ const requestBody = JSON.stringify({
56
+ message,
57
+ chat_history: sanitizedHistory,
58
+ patient_context: patientContext || {},
59
+ session_id: activeSessionId,
60
+ study_context: study_context || null,
61
+ });
62
+
63
+ // ─── SSE Streaming Mode ─────────────────────────────────────────
64
+ if (wantsStream) {
65
+ const pythonUrl = "http://localhost:8001/copilot/chat/stream";
66
+ let pythonResponse: Response;
67
+
68
+ try {
69
+ pythonResponse = await fetch(pythonUrl, {
70
+ method: "POST",
71
+ headers: { "Content-Type": "application/json" },
72
+ body: requestBody,
73
+ });
74
+ } catch (fetchErr: any) {
75
+ if (fetchErr?.cause?.code === 'ECONNREFUSED' || fetchErr?.message?.includes('ECONNREFUSED')) {
76
+ return NextResponse.json({
77
+ message: "⚠️ The AI Copilot backend is not running. Please start the Python service.",
78
+ viewerActions: [],
79
+ references: [],
80
+ });
81
+ }
82
+ throw fetchErr;
83
+ }
84
+
85
+ if (!pythonResponse.ok || !pythonResponse.body) {
86
+ safeError("Python SSE backend error", { status: pythonResponse.status });
87
+ return NextResponse.json({
88
+ message: "⚠️ Could not reach the AI Copilot streaming service.",
89
+ viewerActions: [],
90
+ references: [],
91
+ sessionId: activeSessionId,
92
+ }, { status: 200 });
93
+ }
94
+
95
+ // Create a TransformStream to intercept the 'complete' event for DB saving
96
+ const reader = pythonResponse.body.getReader();
97
+ const decoder = new TextDecoder();
98
+ let sseBuffer = "";
99
+
100
+ const stream = new ReadableStream({
101
+ async start(controller) {
102
+ try {
103
+ while (true) {
104
+ const { done, value } = await reader.read();
105
+ if (done) break;
106
+
107
+ sseBuffer += decoder.decode(value, { stream: true });
108
+ const segments = sseBuffer.split("\n\n");
109
+ sseBuffer = segments.pop() || "";
110
+
111
+ for (const segment of segments) {
112
+ const line = segment.trim();
113
+ if (!line.startsWith("data: ")) {
114
+ // Forward non-data lines as-is
115
+ controller.enqueue(new TextEncoder().encode(segment + "\n\n"));
116
+ continue;
117
+ }
118
+
119
+ try {
120
+ const data = JSON.parse(line.slice(6));
121
+
122
+ if (data.type === "complete") {
123
+ // Enrich with sessionId and forward
124
+ const enriched = { ...data, sessionId: activeSessionId };
125
+ controller.enqueue(new TextEncoder().encode(
126
+ `data: ${JSON.stringify(enriched)}\n\n`
127
+ ));
128
+
129
+ // Save assistant message to SQLite
130
+ try {
131
+ const insertStmt = sqlite.prepare(
132
+ "INSERT INTO chat_messages (session_id, role, content, viewer_actions, references_data, patient_id, created_at) VALUES (?, 'assistant', ?, ?, ?, ?, ?)"
133
+ );
134
+ insertStmt.run(
135
+ activeSessionId,
136
+ data.message || "",
137
+ JSON.stringify(data.viewer_actions || []),
138
+ JSON.stringify(data.references || []),
139
+ patientContext?.patientId || null,
140
+ new Date().toISOString()
141
+ );
142
+ } catch (dbErr) {
143
+ safeError("Error saving streamed assistant message:", dbErr);
144
+ }
145
+ } else {
146
+ // Forward status/error events as-is
147
+ controller.enqueue(new TextEncoder().encode(segment + "\n\n"));
148
+ }
149
+ } catch {
150
+ // Not valid JSON, forward as-is
151
+ controller.enqueue(new TextEncoder().encode(segment + "\n\n"));
152
+ }
153
+ }
154
+ }
155
+ } catch (err) {
156
+ safeError("SSE stream error:", err);
157
+ } finally {
158
+ controller.close();
159
+ }
160
+ },
161
+ });
162
+
163
+ return new Response(stream, {
164
+ headers: {
165
+ "Content-Type": "text/event-stream",
166
+ "Cache-Control": "no-cache",
167
+ "Connection": "keep-alive",
168
+ "X-Accel-Buffering": "no",
169
+ },
170
+ });
171
+ }
172
+
173
+ // ─── Non-Streaming Mode (original behavior) ─────────────────────
174
+ const pythonUrl = "http://localhost:8001/copilot/chat";
175
+ const pythonResponse = await fetch(pythonUrl, {
176
+ method: "POST",
177
+ headers: { "Content-Type": "application/json" },
178
+ body: requestBody,
179
+ });
180
+
181
+ if (!pythonResponse.ok) {
182
+ safeError("Python backend error", { status: pythonResponse.status });
183
+ return NextResponse.json(
184
+ {
185
+ message: "⚠️ Could not reach the AI Copilot service. Make sure the Python backend is running (`npm run dev`).",
186
+ viewer_actions: [],
187
+ references: [],
188
+ sessionId: activeSessionId,
189
+ },
190
+ { status: 200 }
191
+ );
192
+ }
193
+
194
+ const result = await pythonResponse.json();
195
+
196
+ // Save assistant message to SQLite
197
+ try {
198
+ const insertStmt = sqlite.prepare(
199
+ "INSERT INTO chat_messages (session_id, role, content, viewer_actions, references_data, patient_id, created_at) VALUES (?, 'assistant', ?, ?, ?, ?, ?)"
200
+ );
201
+ insertStmt.run(
202
+ activeSessionId,
203
+ result.message || "",
204
+ JSON.stringify(result.viewer_actions || []),
205
+ JSON.stringify(result.references || []),
206
+ patientContext?.patientId || null,
207
+ new Date().toISOString()
208
+ );
209
+ } catch (dbErr) {
210
+ safeError("Error saving assistant message:", dbErr);
211
+ }
212
+
213
+ return NextResponse.json({
214
+ message: result.message,
215
+ viewerActions: result.viewer_actions || [],
216
+ references: result.references || [],
217
+ sessionId: activeSessionId,
218
+ });
219
+ } catch (error: any) {
220
+ if ((error as any)?.statusCode) return handleAuthError(error);
221
+
222
+ // Check if it's a connection error
223
+ if (error?.cause?.code === 'ECONNREFUSED' || error?.message?.includes('ECONNREFUSED')) {
224
+ return NextResponse.json({
225
+ message: "⚠️ The AI Copilot backend is not running. Please start the Python service with `npm run dev` (it runs both Next.js and the AI service).",
226
+ viewerActions: [],
227
+ references: [],
228
+ });
229
+ }
230
+
231
+ safeError("Copilot chat error:", error);
232
+ return NextResponse.json({
233
+ message: "⚠️ An unexpected error occurred. Please try again.",
234
+ viewerActions: [],
235
+ references: [],
236
+ });
237
+ }
238
+ }