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