@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,112 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { db } from "@/db";
|
|
3
|
+
import { reports, patients } from "@/db/schema";
|
|
4
|
+
import { desc, eq } from "drizzle-orm";
|
|
5
|
+
import { randomUUID } from "crypto";
|
|
6
|
+
|
|
7
|
+
// GET /api/reports — List all reports
|
|
8
|
+
export async function GET() {
|
|
9
|
+
try {
|
|
10
|
+
const rows = db.select().from(reports).orderBy(desc(reports.createdAt)).all();
|
|
11
|
+
const parsed = rows.map((r) => {
|
|
12
|
+
const reportData = JSON.parse(r.reportData);
|
|
13
|
+
if (r.imageData && !reportData.image_data) {
|
|
14
|
+
reportData.image_data = r.imageData;
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
id: r.id,
|
|
18
|
+
patient_name: r.patientName,
|
|
19
|
+
modality: r.modality,
|
|
20
|
+
urgency: r.urgency,
|
|
21
|
+
report_status: r.reportStatus,
|
|
22
|
+
report_data: reportData,
|
|
23
|
+
created_at: r.createdAt,
|
|
24
|
+
pacs_study_uid: r.pacsStudyUid,
|
|
25
|
+
pacs_series_uid: r.pacsSeriesUid,
|
|
26
|
+
pacs_source: r.pacsSource,
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
return NextResponse.json(parsed);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
console.error("[API] Error fetching reports:", error);
|
|
32
|
+
return NextResponse.json([], { status: 500 });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// POST /api/reports — Create a new report
|
|
37
|
+
export async function POST(request: NextRequest) {
|
|
38
|
+
try {
|
|
39
|
+
const body = await request.json();
|
|
40
|
+
const { report_data, id } = body;
|
|
41
|
+
|
|
42
|
+
const reportId = id || `local_${Date.now()}`;
|
|
43
|
+
const pName = report_data.patient?.name || "Unknown";
|
|
44
|
+
const pIdNumber = report_data.patient?.patient_id || body.patientIdNumber || null;
|
|
45
|
+
|
|
46
|
+
// Auto-link or auto-create patient
|
|
47
|
+
let linkedPatientId = "";
|
|
48
|
+
|
|
49
|
+
// 1. Try to find by ID number first if provided
|
|
50
|
+
let existing: any[] = [];
|
|
51
|
+
if (pIdNumber) {
|
|
52
|
+
existing = await db.select().from(patients).where(eq(patients.patientIdNumber, pIdNumber)).limit(1);
|
|
53
|
+
}
|
|
54
|
+
// 2. If not found by ID, try exact match by Name
|
|
55
|
+
if (existing.length === 0) {
|
|
56
|
+
existing = await db.select().from(patients).where(eq(patients.patientName, pName)).limit(1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (existing.length > 0) {
|
|
60
|
+
linkedPatientId = existing[0].id;
|
|
61
|
+
|
|
62
|
+
// Backfill any missing patient metadata from the new report
|
|
63
|
+
const updates: Record<string, any> = {};
|
|
64
|
+
const incomingAge = report_data.patient?.age || body.age;
|
|
65
|
+
const incomingDob = report_data.patient?.dob || body.dob;
|
|
66
|
+
const incomingGender = report_data.patient?.gender || body.gender;
|
|
67
|
+
|
|
68
|
+
if (!existing[0].age && incomingAge) updates.age = incomingAge;
|
|
69
|
+
if (!existing[0].dob && incomingDob) updates.dob = incomingDob;
|
|
70
|
+
if (!existing[0].gender && incomingGender) updates.gender = incomingGender;
|
|
71
|
+
|
|
72
|
+
if (Object.keys(updates).length > 0) {
|
|
73
|
+
updates.updatedAt = new Date().toISOString();
|
|
74
|
+
await db.update(patients).set(updates).where(eq(patients.id, linkedPatientId));
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
// Auto-create
|
|
78
|
+
linkedPatientId = randomUUID();
|
|
79
|
+
await db.insert(patients).values({
|
|
80
|
+
id: linkedPatientId,
|
|
81
|
+
patientName: pName,
|
|
82
|
+
patientIdNumber: pIdNumber,
|
|
83
|
+
dob: report_data.patient?.dob || body.dob || null,
|
|
84
|
+
age: report_data.patient?.age || body.age || null,
|
|
85
|
+
gender: report_data.patient?.gender || body.gender || null,
|
|
86
|
+
createdAt: new Date().toISOString(),
|
|
87
|
+
updatedAt: new Date().toISOString()
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Insert report
|
|
92
|
+
db.insert(reports).values({
|
|
93
|
+
id: reportId,
|
|
94
|
+
patientId: linkedPatientId,
|
|
95
|
+
patientName: pName,
|
|
96
|
+
modality: report_data.study?.examination || report_data.study?.modality || "",
|
|
97
|
+
urgency: report_data.urgency || "Routine",
|
|
98
|
+
reportStatus: report_data.report_footer?.report_status || "Pending",
|
|
99
|
+
reportData: JSON.stringify(report_data),
|
|
100
|
+
imageData: report_data.image_data || null,
|
|
101
|
+
createdAt: new Date().toISOString(),
|
|
102
|
+
pacsStudyUid: report_data.pacs_info?.study_uid || null,
|
|
103
|
+
pacsSeriesUid: report_data.pacs_info?.series_uid || null,
|
|
104
|
+
pacsSource: report_data.pacs_info?.source || null,
|
|
105
|
+
}).run();
|
|
106
|
+
|
|
107
|
+
return NextResponse.json({ id: reportId, patientId: linkedPatientId, success: true });
|
|
108
|
+
} catch (error) {
|
|
109
|
+
console.error("[API] Error saving report:", error);
|
|
110
|
+
return NextResponse.json({ error: "Failed to save report" }, { status: 500 });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { sqlite } from "@/db";
|
|
3
|
+
import { requireUser, requirePermission, handleAuthError } from "@/lib/security/authz";
|
|
4
|
+
import { maskSecret } from "@/lib/security/secrets";
|
|
5
|
+
import { safeError } from "@/lib/security/phi-redaction";
|
|
6
|
+
import { auditSuccess, auditEventFromContext } from "@/lib/security/audit";
|
|
7
|
+
|
|
8
|
+
// GET /api/segmentation-config — load active segmentation config
|
|
9
|
+
export async function GET(req: NextRequest) {
|
|
10
|
+
try {
|
|
11
|
+
const ctx = await requireUser(req);
|
|
12
|
+
requirePermission(ctx, "settings.read");
|
|
13
|
+
const row = sqlite
|
|
14
|
+
.prepare("SELECT * FROM segmentation_configurations WHERE is_active = 1 LIMIT 1")
|
|
15
|
+
.get() as any;
|
|
16
|
+
|
|
17
|
+
if (!row) {
|
|
18
|
+
return NextResponse.json({
|
|
19
|
+
deploymentMode: "localhost",
|
|
20
|
+
providerName: "MedSAM3",
|
|
21
|
+
modelName: "",
|
|
22
|
+
modelType: "medsam3",
|
|
23
|
+
baseUrl: "http://localhost:5000",
|
|
24
|
+
healthEndpoint: "/healthz",
|
|
25
|
+
predictEndpoint: "/v1/segmentations",
|
|
26
|
+
apiSecretKey: "",
|
|
27
|
+
timeoutSeconds: 120,
|
|
28
|
+
supportsContours: true,
|
|
29
|
+
supports3D: false,
|
|
30
|
+
returnsMask: true,
|
|
31
|
+
returnsBox: true,
|
|
32
|
+
isActive: false,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return NextResponse.json({
|
|
37
|
+
id: row.id,
|
|
38
|
+
deploymentMode: row.deployment_mode,
|
|
39
|
+
providerName: row.provider_name,
|
|
40
|
+
modelName: row.model_name,
|
|
41
|
+
modelType: row.model_type || "medsam3",
|
|
42
|
+
baseUrl: row.base_url,
|
|
43
|
+
healthEndpoint: row.health_endpoint,
|
|
44
|
+
predictEndpoint: row.predict_endpoint,
|
|
45
|
+
apiSecretKey: maskSecret(row.api_secret_key),
|
|
46
|
+
hasApiKey: !!(row.api_secret_key && row.api_secret_key.length > 0),
|
|
47
|
+
timeoutSeconds: row.timeout_seconds,
|
|
48
|
+
supportsContours: !!row.supports_contours,
|
|
49
|
+
supports3D: !!row.supports_3d,
|
|
50
|
+
returnsMask: !!row.returns_mask,
|
|
51
|
+
returnsBox: !!row.returns_box,
|
|
52
|
+
isActive: !!row.is_active,
|
|
53
|
+
});
|
|
54
|
+
} catch (error: any) {
|
|
55
|
+
if (error?.statusCode) return handleAuthError(error);
|
|
56
|
+
safeError("segmentation-config GET error:", error);
|
|
57
|
+
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// PUT /api/segmentation-config — save segmentation config
|
|
62
|
+
export async function PUT(req: NextRequest) {
|
|
63
|
+
try {
|
|
64
|
+
const ctx = await requireUser(req);
|
|
65
|
+
requirePermission(ctx, "ai.configure");
|
|
66
|
+
const body = await req.json();
|
|
67
|
+
const {
|
|
68
|
+
deploymentMode = "localhost",
|
|
69
|
+
providerName = "MedSAM3",
|
|
70
|
+
modelName = "",
|
|
71
|
+
modelType = "medsam3",
|
|
72
|
+
baseUrl: rawBaseUrl = "http://localhost:5000",
|
|
73
|
+
healthEndpoint = "/healthz",
|
|
74
|
+
predictEndpoint = "/v1/segmentations",
|
|
75
|
+
apiSecretKey = "",
|
|
76
|
+
timeoutSeconds = 120,
|
|
77
|
+
supportsContours = true,
|
|
78
|
+
supports3D = false,
|
|
79
|
+
returnsMask = true,
|
|
80
|
+
returnsBox = true,
|
|
81
|
+
isActive = true,
|
|
82
|
+
} = body;
|
|
83
|
+
|
|
84
|
+
// Sanitize base URL: strip any trailing path segments (e.g. /v1, /api)
|
|
85
|
+
// Keep only the origin (scheme + host + port)
|
|
86
|
+
let baseUrl = rawBaseUrl.trim().replace(/\/+$/, "")
|
|
87
|
+
try {
|
|
88
|
+
const parsed = new URL(baseUrl)
|
|
89
|
+
baseUrl = parsed.origin // strips any path like /v1
|
|
90
|
+
} catch { /* not a valid URL yet, keep as-is */ }
|
|
91
|
+
|
|
92
|
+
const now = new Date().toISOString();
|
|
93
|
+
|
|
94
|
+
// Check if a config already exists
|
|
95
|
+
const existing = sqlite
|
|
96
|
+
.prepare("SELECT id FROM segmentation_configurations LIMIT 1")
|
|
97
|
+
.get() as any;
|
|
98
|
+
|
|
99
|
+
if (existing) {
|
|
100
|
+
sqlite.prepare(`
|
|
101
|
+
UPDATE segmentation_configurations SET
|
|
102
|
+
deployment_mode = ?, provider_name = ?, model_name = ?,
|
|
103
|
+
model_type = ?,
|
|
104
|
+
base_url = ?, health_endpoint = ?, predict_endpoint = ?,
|
|
105
|
+
api_secret_key = ?, timeout_seconds = ?,
|
|
106
|
+
supports_contours = ?, supports_3d = ?,
|
|
107
|
+
returns_mask = ?, returns_box = ?,
|
|
108
|
+
is_active = ?, updated_at = ?
|
|
109
|
+
WHERE id = ?
|
|
110
|
+
`).run(
|
|
111
|
+
deploymentMode, providerName, modelName,
|
|
112
|
+
modelType,
|
|
113
|
+
baseUrl, healthEndpoint, predictEndpoint,
|
|
114
|
+
apiSecretKey || null, timeoutSeconds,
|
|
115
|
+
supportsContours ? 1 : 0, supports3D ? 1 : 0,
|
|
116
|
+
returnsMask ? 1 : 0, returnsBox ? 1 : 0,
|
|
117
|
+
isActive ? 1 : 0, now,
|
|
118
|
+
existing.id
|
|
119
|
+
);
|
|
120
|
+
} else {
|
|
121
|
+
const id = `segconfig_${Date.now()}`;
|
|
122
|
+
sqlite.prepare(`
|
|
123
|
+
INSERT INTO segmentation_configurations
|
|
124
|
+
(id, deployment_mode, provider_name, model_name, model_type, base_url, health_endpoint, predict_endpoint,
|
|
125
|
+
api_secret_key, timeout_seconds, supports_contours, supports_3d, returns_mask, returns_box,
|
|
126
|
+
is_active, created_at, updated_at)
|
|
127
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
128
|
+
`).run(
|
|
129
|
+
id, deploymentMode, providerName, modelName,
|
|
130
|
+
modelType,
|
|
131
|
+
baseUrl, healthEndpoint, predictEndpoint,
|
|
132
|
+
apiSecretKey || null, timeoutSeconds,
|
|
133
|
+
supportsContours ? 1 : 0, supports3D ? 1 : 0,
|
|
134
|
+
returnsMask ? 1 : 0, returnsBox ? 1 : 0,
|
|
135
|
+
isActive ? 1 : 0, now, now
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
await auditSuccess(auditEventFromContext(ctx, "settings.update", "ai", {
|
|
140
|
+
metadata: { providerName, modelName },
|
|
141
|
+
}));
|
|
142
|
+
|
|
143
|
+
return NextResponse.json({ success: true });
|
|
144
|
+
} catch (error: any) {
|
|
145
|
+
if (error?.statusCode) return handleAuthError(error);
|
|
146
|
+
safeError("segmentation-config PUT error:", error);
|
|
147
|
+
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// POST /api/segmentation-config — test connectivity to segmentation backend
|
|
152
|
+
export async function POST(req: NextRequest) {
|
|
153
|
+
try {
|
|
154
|
+
const ctx = await requireUser(req);
|
|
155
|
+
requirePermission(ctx, "ai.configure");
|
|
156
|
+
const body = await req.json();
|
|
157
|
+
const { baseUrl, healthEndpoint, apiSecretKey, timeoutSeconds = 15 } = body;
|
|
158
|
+
|
|
159
|
+
if (!baseUrl) {
|
|
160
|
+
return NextResponse.json({ success: false, error: "Base URL is required." });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Sanitize: strip trailing slashes and any accidental sub-paths (e.g. /v1)
|
|
164
|
+
let cleanBase = baseUrl.trim().replace(/\/+$/, "")
|
|
165
|
+
try {
|
|
166
|
+
const parsed = new URL(cleanBase)
|
|
167
|
+
cleanBase = parsed.origin // keeps only scheme+host+port
|
|
168
|
+
} catch { /* keep as-is */ }
|
|
169
|
+
|
|
170
|
+
const url = `${cleanBase}${healthEndpoint || "/healthz"}`;
|
|
171
|
+
|
|
172
|
+
const controller = new AbortController();
|
|
173
|
+
const timeout = setTimeout(() => controller.abort(), (timeoutSeconds || 15) * 1000);
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
177
|
+
if (apiSecretKey) {
|
|
178
|
+
headers["Authorization"] = `Bearer ${apiSecretKey}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const response = await fetch(url, {
|
|
182
|
+
method: "GET",
|
|
183
|
+
headers,
|
|
184
|
+
signal: controller.signal,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
clearTimeout(timeout);
|
|
188
|
+
|
|
189
|
+
if (response.ok) {
|
|
190
|
+
const data = await response.json().catch(() => ({}));
|
|
191
|
+
|
|
192
|
+
// Fetch available models to populate UI (ignoring errors)
|
|
193
|
+
let availableModels: string[] = [];
|
|
194
|
+
try {
|
|
195
|
+
// Try the OpenAI /v1/models route structure MedSAM3 uses
|
|
196
|
+
const modelsRes = await fetch(`${cleanBase}/v1/models`, {
|
|
197
|
+
method: "GET", headers, signal: AbortSignal.timeout(5000)
|
|
198
|
+
});
|
|
199
|
+
if (modelsRes.ok) {
|
|
200
|
+
const mData = await modelsRes.json();
|
|
201
|
+
if (Array.isArray(mData)) {
|
|
202
|
+
availableModels = mData.map((m: any) => typeof m === "string" ? m : m.id || m.name || String(m));
|
|
203
|
+
} else if (mData?.data) {
|
|
204
|
+
availableModels = mData.data.map((m: any) => typeof m === "string" ? m : m.id || m.name || String(m));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
} catch { /* non-fatal if models fail to fetch */ }
|
|
208
|
+
|
|
209
|
+
return NextResponse.json({
|
|
210
|
+
success: true,
|
|
211
|
+
message: `Connected successfully to ${url}`,
|
|
212
|
+
details: data,
|
|
213
|
+
availableModels: availableModels.filter(Boolean),
|
|
214
|
+
});
|
|
215
|
+
} else {
|
|
216
|
+
const text = await response.text().catch(() => "");
|
|
217
|
+
return NextResponse.json({
|
|
218
|
+
success: false,
|
|
219
|
+
error: `HTTP ${response.status}: ${text.slice(0, 200)}`,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
} catch (fetchErr: any) {
|
|
223
|
+
clearTimeout(timeout);
|
|
224
|
+
if (fetchErr.name === "AbortError") {
|
|
225
|
+
return NextResponse.json({
|
|
226
|
+
success: false,
|
|
227
|
+
error: `Connection timed out after ${timeoutSeconds}s`,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return NextResponse.json({
|
|
231
|
+
success: false,
|
|
232
|
+
error: `Connection failed: ${fetchErr.message}`,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
} catch (error: any) {
|
|
236
|
+
return NextResponse.json({ success: false, error: error.message });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { db } from "@/db";
|
|
3
|
+
import { config, profile, appearance, security, users, sessions } from "@/db/schema";
|
|
4
|
+
import { eq } from "drizzle-orm";
|
|
5
|
+
import { cookies } from "next/headers";
|
|
6
|
+
|
|
7
|
+
// GET /api/settings?type=config|profile|appearance|users
|
|
8
|
+
export async function GET(request: NextRequest) {
|
|
9
|
+
const type = request.nextUrl.searchParams.get("type");
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
switch (type) {
|
|
13
|
+
case "config": {
|
|
14
|
+
const row = db.select().from(config).where(eq(config.id, 1)).get();
|
|
15
|
+
if (!row) {
|
|
16
|
+
return NextResponse.json({
|
|
17
|
+
n8nWebhookUrl: "",
|
|
18
|
+
supabaseUrl: "",
|
|
19
|
+
supabaseAnonKey: "",
|
|
20
|
+
pacsOrthancUrl: "",
|
|
21
|
+
pacsAuthType: "none",
|
|
22
|
+
pacsUsername: "",
|
|
23
|
+
pacsPassword: "",
|
|
24
|
+
pacsBearerToken: "",
|
|
25
|
+
pacsAeTitle: "",
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return NextResponse.json({
|
|
29
|
+
n8nWebhookUrl: row.n8nWebhookUrl || "",
|
|
30
|
+
supabaseUrl: row.supabaseUrl || "",
|
|
31
|
+
supabaseAnonKey: row.supabaseAnonKey || "",
|
|
32
|
+
pacsOrthancUrl: row.pacsOrthancUrl || "",
|
|
33
|
+
pacsAuthType: row.pacsAuthType || "none",
|
|
34
|
+
pacsUsername: row.pacsUsername || "",
|
|
35
|
+
pacsPassword: row.pacsPassword || "",
|
|
36
|
+
pacsBearerToken: row.pacsBearerToken || "",
|
|
37
|
+
pacsAeTitle: row.pacsAeTitle || "",
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
case "profile": {
|
|
41
|
+
const row = db.select().from(profile).where(eq(profile.id, 1)).get();
|
|
42
|
+
if (!row) {
|
|
43
|
+
return NextResponse.json({
|
|
44
|
+
fullName: "",
|
|
45
|
+
role: "",
|
|
46
|
+
hospitalName: "",
|
|
47
|
+
department: "",
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return NextResponse.json({
|
|
51
|
+
fullName: row.fullName || "",
|
|
52
|
+
role: row.role || "",
|
|
53
|
+
hospitalName: row.hospitalName || "",
|
|
54
|
+
department: row.department || "",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
case "appearance": {
|
|
58
|
+
const row = db.select().from(appearance).where(eq(appearance.id, 1)).get();
|
|
59
|
+
if (!row) {
|
|
60
|
+
return NextResponse.json({
|
|
61
|
+
theme: "dark",
|
|
62
|
+
template: "standard",
|
|
63
|
+
hospitalName: "",
|
|
64
|
+
logo: "",
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return NextResponse.json({
|
|
68
|
+
theme: row.theme || "dark",
|
|
69
|
+
template: row.template || "standard",
|
|
70
|
+
hospitalName: row.hospitalName || "",
|
|
71
|
+
logo: row.logo || "",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
case "security": {
|
|
76
|
+
const row = db.select().from(security).where(eq(security.id, 1)).get();
|
|
77
|
+
if (!row) {
|
|
78
|
+
return NextResponse.json({
|
|
79
|
+
appLockEnabled: true,
|
|
80
|
+
defaultUserId: null,
|
|
81
|
+
updatedBy: null,
|
|
82
|
+
updatedAt: null,
|
|
83
|
+
updatedByName: null,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
// Get the name of who last changed it
|
|
87
|
+
let updatedByName = null;
|
|
88
|
+
if (row.updatedBy) {
|
|
89
|
+
const updater = db.select({ fullName: users.fullName }).from(users).where(eq(users.id, row.updatedBy)).get();
|
|
90
|
+
updatedByName = updater?.fullName || null;
|
|
91
|
+
}
|
|
92
|
+
return NextResponse.json({
|
|
93
|
+
appLockEnabled: row.appLockEnabled ?? true,
|
|
94
|
+
defaultUserId: row.defaultUserId || null,
|
|
95
|
+
updatedBy: row.updatedBy || null,
|
|
96
|
+
updatedAt: row.updatedAt || null,
|
|
97
|
+
updatedByName,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
default:
|
|
102
|
+
return NextResponse.json({ error: "Invalid type. Use: config, profile, appearance, security, or users" }, { status: 400 });
|
|
103
|
+
}
|
|
104
|
+
} catch (error) {
|
|
105
|
+
console.error("[API] Error reading settings:", error);
|
|
106
|
+
return NextResponse.json({ error: "Failed to read settings" }, { status: 500 });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// PUT /api/settings — Upsert settings
|
|
111
|
+
export async function PUT(request: NextRequest) {
|
|
112
|
+
try {
|
|
113
|
+
const body = await request.json();
|
|
114
|
+
const { type, data } = body;
|
|
115
|
+
|
|
116
|
+
switch (type) {
|
|
117
|
+
case "config": {
|
|
118
|
+
const exists = db.select().from(config).where(eq(config.id, 1)).get();
|
|
119
|
+
if (exists) {
|
|
120
|
+
db.update(config).set({
|
|
121
|
+
n8nWebhookUrl: data.n8nWebhookUrl || "",
|
|
122
|
+
supabaseUrl: data.supabaseUrl || "",
|
|
123
|
+
supabaseAnonKey: data.supabaseAnonKey || "",
|
|
124
|
+
pacsOrthancUrl: data.pacsOrthancUrl || "",
|
|
125
|
+
pacsAuthType: data.pacsAuthType || "none",
|
|
126
|
+
pacsUsername: data.pacsUsername || "",
|
|
127
|
+
pacsPassword: data.pacsPassword || "",
|
|
128
|
+
pacsBearerToken: data.pacsBearerToken || "",
|
|
129
|
+
pacsAeTitle: data.pacsAeTitle || "",
|
|
130
|
+
}).where(eq(config.id, 1)).run();
|
|
131
|
+
} else {
|
|
132
|
+
db.insert(config).values({
|
|
133
|
+
id: 1,
|
|
134
|
+
n8nWebhookUrl: data.n8nWebhookUrl || "",
|
|
135
|
+
supabaseUrl: data.supabaseUrl || "",
|
|
136
|
+
supabaseAnonKey: data.supabaseAnonKey || "",
|
|
137
|
+
pacsOrthancUrl: data.pacsOrthancUrl || "",
|
|
138
|
+
pacsAuthType: data.pacsAuthType || "none",
|
|
139
|
+
pacsUsername: data.pacsUsername || "",
|
|
140
|
+
pacsPassword: data.pacsPassword || "",
|
|
141
|
+
pacsBearerToken: data.pacsBearerToken || "",
|
|
142
|
+
pacsAeTitle: data.pacsAeTitle || "",
|
|
143
|
+
}).run();
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
case "profile": {
|
|
148
|
+
const exists = db.select().from(profile).where(eq(profile.id, 1)).get();
|
|
149
|
+
if (exists) {
|
|
150
|
+
db.update(profile).set({
|
|
151
|
+
fullName: data.fullName || "",
|
|
152
|
+
role: data.role || "",
|
|
153
|
+
hospitalName: data.hospitalName || "",
|
|
154
|
+
department: data.department || "",
|
|
155
|
+
}).where(eq(profile.id, 1)).run();
|
|
156
|
+
} else {
|
|
157
|
+
db.insert(profile).values({
|
|
158
|
+
id: 1,
|
|
159
|
+
fullName: data.fullName || "",
|
|
160
|
+
role: data.role || "",
|
|
161
|
+
hospitalName: data.hospitalName || "",
|
|
162
|
+
department: data.department || "",
|
|
163
|
+
}).run();
|
|
164
|
+
}
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
case "appearance": {
|
|
168
|
+
const exists = db.select().from(appearance).where(eq(appearance.id, 1)).get();
|
|
169
|
+
if (exists) {
|
|
170
|
+
db.update(appearance).set({
|
|
171
|
+
theme: data.theme || "dark",
|
|
172
|
+
template: data.template || "standard",
|
|
173
|
+
hospitalName: data.hospitalName || "",
|
|
174
|
+
logo: data.logo || "",
|
|
175
|
+
}).where(eq(appearance.id, 1)).run();
|
|
176
|
+
} else {
|
|
177
|
+
db.insert(appearance).values({
|
|
178
|
+
id: 1,
|
|
179
|
+
theme: data.theme || "dark",
|
|
180
|
+
template: data.template || "standard",
|
|
181
|
+
hospitalName: data.hospitalName || "",
|
|
182
|
+
logo: data.logo || "",
|
|
183
|
+
}).run();
|
|
184
|
+
}
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
case "security": {
|
|
189
|
+
// Admin-only: verify the current user is an admin
|
|
190
|
+
const cookieStore = await cookies();
|
|
191
|
+
const sessionId = cookieStore.get('omnirad_session_id')?.value;
|
|
192
|
+
if (!sessionId) {
|
|
193
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
194
|
+
}
|
|
195
|
+
const sessionList = db.select().from(sessions).where(eq(sessions.id, sessionId)).all();
|
|
196
|
+
const session = sessionList[0];
|
|
197
|
+
if (!session || session.expiresAt * 1000 < Date.now()) {
|
|
198
|
+
return NextResponse.json({ error: "Session expired" }, { status: 401 });
|
|
199
|
+
}
|
|
200
|
+
const currentUser = db.select().from(users).where(eq(users.id, session.userId)).get();
|
|
201
|
+
if (!currentUser || currentUser.role !== 'Admin') {
|
|
202
|
+
return NextResponse.json({ error: "Only administrators can change security settings" }, { status: 403 });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const appLockEnabled = data.appLockEnabled ?? true;
|
|
206
|
+
const defaultUserId = data.defaultUserId || null;
|
|
207
|
+
const secExists = db.select().from(security).where(eq(security.id, 1)).get();
|
|
208
|
+
const secData = {
|
|
209
|
+
appLockEnabled,
|
|
210
|
+
defaultUserId,
|
|
211
|
+
updatedBy: currentUser.id,
|
|
212
|
+
updatedAt: new Date().toISOString(),
|
|
213
|
+
};
|
|
214
|
+
if (secExists) {
|
|
215
|
+
db.update(security).set(secData).where(eq(security.id, 1)).run();
|
|
216
|
+
} else {
|
|
217
|
+
db.insert(security).values({ id: 1, ...secData }).run();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Set or clear the middleware signal cookie
|
|
221
|
+
if (!appLockEnabled) {
|
|
222
|
+
cookieStore.set('omnirad_app_unlocked', 'true', {
|
|
223
|
+
httpOnly: false,
|
|
224
|
+
secure: process.env.NODE_ENV === 'production',
|
|
225
|
+
sameSite: 'lax',
|
|
226
|
+
path: '/',
|
|
227
|
+
maxAge: 60 * 60 * 24 * 365, // 1 year
|
|
228
|
+
});
|
|
229
|
+
} else {
|
|
230
|
+
cookieStore.delete('omnirad_app_unlocked');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
default:
|
|
237
|
+
return NextResponse.json({ error: "Invalid type" }, { status: 400 });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return NextResponse.json({ success: true });
|
|
241
|
+
} catch (error) {
|
|
242
|
+
console.error("[API] Error saving settings:", error);
|
|
243
|
+
return NextResponse.json({ error: "Failed to save settings" }, { status: 500 });
|
|
244
|
+
}
|
|
245
|
+
}
|