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