@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
package/README.md
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="public/logo.svg" alt="OmniRad Logo" width="120" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">OmniRad</h1>
|
|
6
|
+
<p align="center">
|
|
7
|
+
<strong>Open-Source AI-Powered Radiology Workstation</strong>
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
<p align="center">
|
|
11
|
+
<a href="#-features">Features</a> •
|
|
12
|
+
<a href="#-architecture">Architecture</a> •
|
|
13
|
+
<a href="#-getting-started">Getting Started</a> •
|
|
14
|
+
<a href="#-ai-providers">AI Providers</a> •
|
|
15
|
+
<a href="#-pacs--dicom">PACS & DICOM</a> •
|
|
16
|
+
<a href="#-fhir-r4-integration">FHIR R4</a> •
|
|
17
|
+
<a href="#-security--compliance">Security</a> •
|
|
18
|
+
<a href="#-contributing">Contributing</a>
|
|
19
|
+
</p>
|
|
20
|
+
|
|
21
|
+
<p align="center">
|
|
22
|
+
<img src="https://img.shields.io/badge/Next.js-16-black?logo=next.js" alt="Next.js 16" />
|
|
23
|
+
<img src="https://img.shields.io/badge/React-19-61DAFB?logo=react" alt="React 19" />
|
|
24
|
+
<img src="https://img.shields.io/badge/Tailwind_CSS-4-38BDF8?logo=tailwindcss" alt="Tailwind CSS v4" />
|
|
25
|
+
<img src="https://img.shields.io/badge/LangGraph-Agent-FF6B35" alt="LangGraph" />
|
|
26
|
+
<img src="https://img.shields.io/badge/Python-3.13-3776AB?logo=python" alt="Python 3.13" />
|
|
27
|
+
<img src="https://img.shields.io/badge/License-MIT-green" alt="MIT License" />
|
|
28
|
+
</p>
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## 📸 Screenshots
|
|
33
|
+
|
|
34
|
+
<p align="center">
|
|
35
|
+
<img width="960" alt="OmniRad Dashboard — Report Generation" src="https://github.com/omersx/assets/9eb9f1ca-fe2f-4bff-90ff-20f8026e9c02" />
|
|
36
|
+
</p>
|
|
37
|
+
<p align="center"><em>Dashboard — Generate AI-powered radiology reports from patient data & medical images</em></p>
|
|
38
|
+
|
|
39
|
+
<br />
|
|
40
|
+
|
|
41
|
+
<p align="center">
|
|
42
|
+
<img width="960" alt="OmniRad Reports — History & Management" src="https://github.com/omersx/assets/ad066ccf-a530-47bf-8f15-80cbf91e3aa0" />
|
|
43
|
+
</p>
|
|
44
|
+
<p align="center"><em>Reports History — Review, approve, edit, and export reports with full audit trail</em></p>
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## ✨ Features
|
|
49
|
+
|
|
50
|
+
### 🧠 AI Report Generation
|
|
51
|
+
- Upload medical images (X-Ray, CT, MRI, Ultrasound) with patient context and receive structured, clinically formatted radiology reports in seconds.
|
|
52
|
+
- Powered by **LangGraph** multi-step agent workflow with structured output parsing and automatic JSON extraction fallback.
|
|
53
|
+
- Reports include **Findings** (with anatomical regions & status), **Impression**, **Urgency classification**, and **Recommendations**.
|
|
54
|
+
|
|
55
|
+
### 🤖 AI Copilot Workspace
|
|
56
|
+
- Interactive **split-pane workspace** combining a DICOM image viewer with a conversational AI assistant.
|
|
57
|
+
- **Streaming SSE responses** with real-time activity indicators (Thinking → Searching → Fetching → Generating).
|
|
58
|
+
- Natural language commands to search patients, retrieve reports, compare studies, and navigate imaging data.
|
|
59
|
+
- **AI-powered image segmentation & annotation** — ask the copilot to highlight findings, segment structures, or annotate report-grounded observations directly on the image.
|
|
60
|
+
- Annotation styles automatically adapt: **arrows** for pointing, **circles** for lesions, **bounding boxes** for localization, **overlays** for diffuse findings.
|
|
61
|
+
- **Slice navigation** — ask "take me to the slice with the lesion" and the copilot locates and navigates to the relevant slice.
|
|
62
|
+
- Persistent **chat history** with session management.
|
|
63
|
+
|
|
64
|
+
### 🖼️ Medical Image Viewing
|
|
65
|
+
- Built-in **Cornerstone.js v4** DICOM viewer with full rendering pipeline.
|
|
66
|
+
- Window/Level adjustment, zoom, pan, and standard radiological tools.
|
|
67
|
+
- Support for multi-frame / multi-slice DICOM series with slice navigation.
|
|
68
|
+
- Inline image viewer for standard formats (JPEG, PNG) when DICOM is unavailable.
|
|
69
|
+
|
|
70
|
+
### 🏥 PACS / DICOMweb Integration
|
|
71
|
+
- Connect to any **Orthanc** or DICOMweb-compliant PACS server.
|
|
72
|
+
- Browse, search, and filter studies by patient name, modality, date range, and study description.
|
|
73
|
+
- Pull individual series into the DICOM viewer or directly into report generation.
|
|
74
|
+
- Configurable authentication: **None**, **Basic Auth**, or **Bearer Token**.
|
|
75
|
+
|
|
76
|
+
### 🔗 HL7 FHIR R4 Integration
|
|
77
|
+
- Expose and consume **FHIR R4** resources:
|
|
78
|
+
- `Patient` — demographics mapping
|
|
79
|
+
- `DiagnosticReport` — structured report output
|
|
80
|
+
- `ImagingStudy` — study references
|
|
81
|
+
- `ServiceRequest` — order management
|
|
82
|
+
- Connect to external FHIR servers (Epic, Cerner, HAPI FHIR, etc.) for bidirectional data exchange.
|
|
83
|
+
|
|
84
|
+
### 👥 Patient Management
|
|
85
|
+
- Full **patient registry** with demographics, contact info, and clinical notes.
|
|
86
|
+
- Patient search with fuzzy matching.
|
|
87
|
+
- **Timeline view** showing all reports, studies, and interactions per patient.
|
|
88
|
+
- Patient-linked reports with cascade deletion.
|
|
89
|
+
|
|
90
|
+
### 📄 Report Management & Export
|
|
91
|
+
- Three built-in report **templates**: Standard, Modern, and Minimal.
|
|
92
|
+
- Rich inline **report editor** for radiologist review and modification.
|
|
93
|
+
- **Approval workflow** — Approve, Reject, or mark as Pending with audit logging.
|
|
94
|
+
- **PDF export** with hospital branding (custom logo + hospital name), digital signature support, and professional formatting.
|
|
95
|
+
- Full report overlay view with section-by-section navigation.
|
|
96
|
+
|
|
97
|
+
### 💾 Hybrid Storage Model
|
|
98
|
+
| Layer | Technology | Purpose |
|
|
99
|
+
|-------|-----------|---------|
|
|
100
|
+
| **Local** | SQLite + Drizzle ORM | Primary storage, offline-first, full DICOM image caching |
|
|
101
|
+
| **Cloud** | Supabase (PostgreSQL) | Optional cloud sync — images are auto-stripped before upload to save bandwidth |
|
|
102
|
+
|
|
103
|
+
### 👤 User Management & Authentication
|
|
104
|
+
- **Multi-user support** with role-based access (**Admin** / **User**).
|
|
105
|
+
- Session-based authentication with secure cookie management.
|
|
106
|
+
- First-run **setup wizard** for initial admin account creation.
|
|
107
|
+
- **App Lock toggle** — admins can disable login requirements for single-user deployments.
|
|
108
|
+
- Auto-login flow for unlocked mode.
|
|
109
|
+
|
|
110
|
+
### ⚙️ Flexible AI Configuration
|
|
111
|
+
- Configure **separate AI providers** for Report Generation and Copilot.
|
|
112
|
+
- Support for multiple LLM providers (see [AI Providers](#-ai-providers)).
|
|
113
|
+
- Per-provider settings: model selection, temperature, max tokens, timeout.
|
|
114
|
+
- **LangSmith** integration for AI observability and tracing.
|
|
115
|
+
- Built-in **connection test** with automatic model discovery.
|
|
116
|
+
|
|
117
|
+
### 🎨 Appearance & Branding
|
|
118
|
+
- **Dark / Light mode** with zero-flash theme switching.
|
|
119
|
+
- Customizable **hospital branding** — logo upload and hospital name for all exports.
|
|
120
|
+
- Switchable **report templates** with live preview.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## 🏗 Architecture
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
┌──────────────────────────────────────────────────────────────┐
|
|
128
|
+
│ OmniRad Application │
|
|
129
|
+
│ │
|
|
130
|
+
│ ┌────────────────────┐ ┌────────────────────────┐ │
|
|
131
|
+
│ │ Next.js 16 App │ │ Python AI Service │ │
|
|
132
|
+
│ │ (React 19 + SSR) │◄─────►│ (FastAPI + LangGraph)│ │
|
|
133
|
+
│ │ │ REST │ │ │
|
|
134
|
+
│ │ • Dashboard │ │ • Report Generation │ │
|
|
135
|
+
│ │ • Copilot UI │ SSE │ • Copilot Agent │ │
|
|
136
|
+
│ │ • PACS Browser │◄─────►│ • Segmentation │ │
|
|
137
|
+
│ │ • Patient Mgmt │ │ • AI Annotation │ │
|
|
138
|
+
│ │ • Report Viewer │ │ │ │
|
|
139
|
+
│ │ • Settings │ │ Providers: │ │
|
|
140
|
+
│ │ • Admin Panel │ │ ├─ Google Gemini │ │
|
|
141
|
+
│ └────────┬───────────┘ │ ├─ OpenAI / Azure │ │
|
|
142
|
+
│ │ │ ├─ Ollama (local) │ │
|
|
143
|
+
│ │ │ └─ Any OpenAI-compat. │ │
|
|
144
|
+
│ ┌────────▼───────────┐ └────────────────────────┘ │
|
|
145
|
+
│ │ Data Layer │ │
|
|
146
|
+
│ │ ┌──────────────┐ │ ┌──────────────┐ ┌──────────────┐ │
|
|
147
|
+
│ │ │ SQLite │ │ │ Supabase │ │ PACS/Orthanc │ │
|
|
148
|
+
│ │ │ (Drizzle ORM)│ │ │ (Cloud Sync) │ │ (DICOMweb) │ │
|
|
149
|
+
│ │ └──────────────┘ │ └──────────────┘ └──────────────┘ │
|
|
150
|
+
│ └────────────────────┘ │
|
|
151
|
+
└──────────────────────────────────────────────────────────────┘
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Tech Stack
|
|
155
|
+
|
|
156
|
+
| Component | Technology |
|
|
157
|
+
|-----------|-----------|
|
|
158
|
+
| **Frontend** | Next.js 16, React 19, Tailwind CSS v4 |
|
|
159
|
+
| **AI Backend** | Python 3.13, FastAPI, LangGraph, LangChain |
|
|
160
|
+
| **DICOM Viewer** | Cornerstone.js v4 (core + tools + DICOM loader) |
|
|
161
|
+
| **Local Database** | SQLite via better-sqlite3 + Drizzle ORM |
|
|
162
|
+
| **Cloud Database** | Supabase (PostgreSQL) |
|
|
163
|
+
| **PDF Export** | html2pdf.js |
|
|
164
|
+
| **Auth** | bcryptjs + cookie-based sessions |
|
|
165
|
+
| **Icons** | lucide-react |
|
|
166
|
+
| **FHIR** | Custom HL7 FHIR R4 client |
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## 🚀 Getting Started
|
|
171
|
+
|
|
172
|
+
### Prerequisites
|
|
173
|
+
|
|
174
|
+
| Requirement | Version |
|
|
175
|
+
|------------|---------|
|
|
176
|
+
| **Node.js** | ≥ 18 |
|
|
177
|
+
| **Python** | ≥ 3.13 |
|
|
178
|
+
| **uv** | latest ([install](https://docs.astral.sh/uv/getting-started/installation/)) |
|
|
179
|
+
|
|
180
|
+
### Installation
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
# 1. Clone the repository
|
|
184
|
+
git clone https://github.com/omniradiology/omnirad.git
|
|
185
|
+
cd omnirad
|
|
186
|
+
|
|
187
|
+
# 2. Install Node.js dependencies
|
|
188
|
+
npm install
|
|
189
|
+
|
|
190
|
+
# 3. Install Python AI service dependencies
|
|
191
|
+
cd ai_service
|
|
192
|
+
uv sync
|
|
193
|
+
cd ..
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Running the App
|
|
197
|
+
|
|
198
|
+
OmniRad runs **two services concurrently** — the Next.js frontend and the Python AI backend:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
# Start both services with a single command
|
|
202
|
+
npm run dev
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
This uses `concurrently` to launch:
|
|
206
|
+
- **Next.js** on `http://localhost:3000`
|
|
207
|
+
- **AI Service** (FastAPI) on `http://localhost:8001`
|
|
208
|
+
|
|
209
|
+
Alternatively, run them separately:
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
# Terminal 1 — Next.js frontend
|
|
213
|
+
npm run dev:next
|
|
214
|
+
|
|
215
|
+
# Terminal 2 — Python AI backend
|
|
216
|
+
cd ai_service && python -m uv run main.py
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### First-Time Setup
|
|
220
|
+
|
|
221
|
+
1. Open `http://localhost:3000` — you'll be redirected to the **Setup Wizard**.
|
|
222
|
+
2. Create your **admin account** (username, email, password).
|
|
223
|
+
3. Navigate to **Settings → AI Configuration** to connect your AI provider.
|
|
224
|
+
4. Start generating reports from the **Dashboard**!
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## 🤖 AI Providers
|
|
229
|
+
|
|
230
|
+
OmniRad supports multiple LLM providers out of the box. Configure them in **Settings → AI Configuration**:
|
|
231
|
+
|
|
232
|
+
| Provider | Type | Vision Support | Notes |
|
|
233
|
+
|----------|------|:--------------:|-------|
|
|
234
|
+
| **Google Gemini** | Cloud API | ✅ | Recommended. Models auto-discovered via API. |
|
|
235
|
+
| **OpenAI** | Cloud API | ✅ | GPT-4o, GPT-4 Turbo, etc. |
|
|
236
|
+
| **Azure OpenAI** | Cloud API | ✅ | Use your Azure endpoint URL. |
|
|
237
|
+
| **Ollama** | Local | ✅ | Run models locally. Zero cloud dependency. |
|
|
238
|
+
| **Any OpenAI-compatible** | Custom API | Varies | LM Studio, vLLM, Together AI, Groq, etc. |
|
|
239
|
+
|
|
240
|
+
> **Dual-provider setup**: Configure one provider for **Report Generation** and a different one for **AI Copilot** (e.g., a fast local model for copilot, a powerful cloud model for reports).
|
|
241
|
+
|
|
242
|
+
### LangSmith Observability
|
|
243
|
+
|
|
244
|
+
Enable AI tracing by adding your LangSmith API key in the AI configuration panel. All LangGraph runs are automatically traced to your project dashboard.
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## 🏥 PACS & DICOM
|
|
249
|
+
|
|
250
|
+
### Connecting to a PACS Server
|
|
251
|
+
|
|
252
|
+
1. Go to **Settings → PACS Configuration**.
|
|
253
|
+
2. Enter your Orthanc / DICOMweb server URL (e.g., `http://localhost:8042`).
|
|
254
|
+
3. Select authentication type and provide credentials if required.
|
|
255
|
+
4. Save — the PACS browser is now available from the sidebar.
|
|
256
|
+
|
|
257
|
+
### Supported PACS Features
|
|
258
|
+
|
|
259
|
+
- **Study-level browsing** with search filters (patient name, modality, date range)
|
|
260
|
+
- **Series-level viewing** with thumbnail previews
|
|
261
|
+
- **Direct DICOM rendering** via Cornerstone.js
|
|
262
|
+
- **Import to report** — pull PACS studies directly into the report generation workflow
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## 🔗 FHIR R4 Integration
|
|
267
|
+
|
|
268
|
+
OmniRad implements an **HL7 FHIR R4** interface for healthcare interoperability:
|
|
269
|
+
|
|
270
|
+
### Exposed Resources
|
|
271
|
+
|
|
272
|
+
| Resource | Endpoint | Description |
|
|
273
|
+
|----------|----------|-------------|
|
|
274
|
+
| `Patient` | `/api/fhir/Patient` | Patient demographics |
|
|
275
|
+
| `DiagnosticReport` | `/api/fhir/DiagnosticReport` | Structured radiology reports |
|
|
276
|
+
| `ImagingStudy` | `/api/fhir/ImagingStudy` | Study references & metadata |
|
|
277
|
+
| `ServiceRequest` | `/api/fhir/ServiceRequest` | Radiology orders |
|
|
278
|
+
|
|
279
|
+
### External FHIR Server
|
|
280
|
+
|
|
281
|
+
Connect to external FHIR servers (Epic, Cerner, HAPI FHIR) via the **Settings → FHIR Integration** panel to enable bidirectional patient and report exchange.
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## 🔒 Security & Compliance
|
|
286
|
+
|
|
287
|
+
OmniRad includes built-in security features designed with healthcare compliance in mind:
|
|
288
|
+
|
|
289
|
+
| Feature | Implementation |
|
|
290
|
+
|---------|---------------|
|
|
291
|
+
| **Audit Trail** | Immutable audit logs for all PHI access and modifications (HIPAA §164.312(b)) |
|
|
292
|
+
| **PHI Redaction** | Automatic redaction of Protected Health Information from server logs |
|
|
293
|
+
| **Role-Based Access** | Admin / User roles with route-level authorization |
|
|
294
|
+
| **Rate Limiting** | Per-endpoint rate limiting to prevent abuse |
|
|
295
|
+
| **Secrets Management** | Encrypted storage for API keys and credentials |
|
|
296
|
+
| **Session Security** | Secure, httpOnly cookie-based sessions with expiration |
|
|
297
|
+
| **RBAC Enforcement** | Server-side authorization checks on all API routes |
|
|
298
|
+
| **Safe Logging** | PHI-aware logging utilities (`safeLog`, `safeError`, `safeWarn`) |
|
|
299
|
+
|
|
300
|
+
> ⚠️ **Disclaimer**: OmniRad is an open-source project and is **not** certified for clinical use. Always consult with your compliance team before deploying in a production healthcare environment. AI-generated reports must be reviewed by a qualified radiologist.
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
## 🗄️ Supabase Cloud Sync (Optional)
|
|
305
|
+
|
|
306
|
+
Enable cloud sync to access your reports from any device:
|
|
307
|
+
|
|
308
|
+
1. Create a project at [supabase.com](https://supabase.com).
|
|
309
|
+
2. Run the following SQL in your Supabase SQL Editor:
|
|
310
|
+
|
|
311
|
+
```sql
|
|
312
|
+
-- Create the reports table
|
|
313
|
+
CREATE TABLE public.reports (
|
|
314
|
+
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
|
315
|
+
created_at TIMESTAMPTZ DEFAULT timezone('utc'::text, now()) NOT NULL,
|
|
316
|
+
patient_name TEXT,
|
|
317
|
+
modality TEXT,
|
|
318
|
+
urgency TEXT,
|
|
319
|
+
report_status TEXT DEFAULT 'Pending',
|
|
320
|
+
report_data JSONB NOT NULL
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
-- Enable Row Level Security
|
|
324
|
+
ALTER TABLE public.reports ENABLE ROW LEVEL SECURITY;
|
|
325
|
+
|
|
326
|
+
-- Create access policy (restrict in production)
|
|
327
|
+
CREATE POLICY "Enable all access for all users" ON public.reports
|
|
328
|
+
FOR ALL USING (true) WITH CHECK (true);
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
3. Copy your **Project URL** and **Anon Key** from Supabase → Settings → API.
|
|
332
|
+
4. Paste them into OmniRad **Settings → Cloud Sync**.
|
|
333
|
+
|
|
334
|
+
> **Note**: DICOM images are automatically stripped before cloud upload to minimize bandwidth and storage costs. Full image data is always retained locally.
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
|
|
338
|
+
## 📁 Project Structure
|
|
339
|
+
|
|
340
|
+
```
|
|
341
|
+
omnirad/
|
|
342
|
+
├── app/ # Next.js App Router pages
|
|
343
|
+
│ ├── api/ # API routes (REST + FHIR)
|
|
344
|
+
│ │ ├── ai-config/ # AI provider management
|
|
345
|
+
│ │ ├── auth/ # Authentication endpoints
|
|
346
|
+
│ │ ├── compliance/ # Compliance & audit APIs
|
|
347
|
+
│ │ ├── copilot/ # Copilot proxy endpoints
|
|
348
|
+
│ │ ├── fhir/ # FHIR R4 resource endpoints
|
|
349
|
+
│ │ ├── pacs/ # PACS/DICOMweb proxy
|
|
350
|
+
│ │ ├── patients/ # Patient CRUD
|
|
351
|
+
│ │ ├── reports/ # Report CRUD & export
|
|
352
|
+
│ │ └── settings/ # App configuration
|
|
353
|
+
│ ├── copilot/ # AI Copilot workspace page
|
|
354
|
+
│ ├── history/ # Report history page
|
|
355
|
+
│ ├── login/ # Login page
|
|
356
|
+
│ ├── pacs/ # PACS browser page
|
|
357
|
+
│ ├── patients/ # Patient management page
|
|
358
|
+
│ ├── reports/ # Report detail pages
|
|
359
|
+
│ ├── settings/ # Settings page
|
|
360
|
+
│ ├── setup/ # First-run setup wizard
|
|
361
|
+
│ └── page.tsx # Dashboard (report generation)
|
|
362
|
+
├── ai_service/ # Python AI backend
|
|
363
|
+
│ ├── agent/ # LangGraph agent workflows
|
|
364
|
+
│ │ ├── workflow.py # Report generation pipeline
|
|
365
|
+
│ │ ├── copilot_workflow.py # Copilot chat agent
|
|
366
|
+
│ │ ├── copilot_tools.py # LangChain tools (search, retrieve, view)
|
|
367
|
+
│ │ └── segmentation_tools.py # AI segmentation & annotation
|
|
368
|
+
│ ├── models/ # Pydantic models & AI model services
|
|
369
|
+
│ ├── main.py # FastAPI entry point
|
|
370
|
+
│ └── pyproject.toml # Python dependencies (uv)
|
|
371
|
+
├── components/ # React UI components
|
|
372
|
+
│ ├── copilot/ # Copilot workspace components
|
|
373
|
+
│ ├── dashboard/ # Dashboard & report generation
|
|
374
|
+
│ ├── pacs/ # PACS browser components
|
|
375
|
+
│ ├── patients/ # Patient management UI
|
|
376
|
+
│ ├── settings/ # Settings panels
|
|
377
|
+
│ ├── admin/ # Admin panel (audit logs)
|
|
378
|
+
│ ├── layout/ # App shell (sidebar, header)
|
|
379
|
+
│ └── ui/ # Shared UI primitives
|
|
380
|
+
├── db/ # Database schema & migrations
|
|
381
|
+
│ ├── schema.ts # Drizzle ORM schema definitions
|
|
382
|
+
│ └── index.ts # Database connection & initialization
|
|
383
|
+
├── lib/ # Shared utilities
|
|
384
|
+
│ ├── api.ts # Client-side API helpers
|
|
385
|
+
│ ├── fhir/ # FHIR R4 resource builders
|
|
386
|
+
│ ├── pacs/ # DICOMweb client utilities
|
|
387
|
+
│ ├── security/ # Audit, RBAC, PHI redaction, rate limiting
|
|
388
|
+
│ ├── dicomImageExtractor.ts
|
|
389
|
+
│ ├── dicomMetadataParser.ts
|
|
390
|
+
│ ├── pdfHelper.ts # PDF generation
|
|
391
|
+
│ └── reportHtmlGenerator.ts # Report template renderer
|
|
392
|
+
├── types/ # TypeScript type definitions
|
|
393
|
+
├── public/ # Static assets (logos, icons)
|
|
394
|
+
├── drizzle.config.ts # Drizzle ORM configuration
|
|
395
|
+
├── middleware.ts # Auth & setup middleware
|
|
396
|
+
├── next.config.ts # Next.js configuration
|
|
397
|
+
└── package.json
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
---
|
|
401
|
+
|
|
402
|
+
## 🤝 Contributing
|
|
403
|
+
|
|
404
|
+
Contributions are welcome! To get started:
|
|
405
|
+
|
|
406
|
+
1. **Fork** the repository
|
|
407
|
+
2. **Create** a feature branch
|
|
408
|
+
```bash
|
|
409
|
+
git checkout -b feature/amazing-feature
|
|
410
|
+
```
|
|
411
|
+
3. **Commit** your changes
|
|
412
|
+
```bash
|
|
413
|
+
git commit -m "feat: add amazing feature"
|
|
414
|
+
```
|
|
415
|
+
4. **Push** to your branch
|
|
416
|
+
```bash
|
|
417
|
+
git push origin feature/amazing-feature
|
|
418
|
+
```
|
|
419
|
+
5. **Open** a Pull Request
|
|
420
|
+
|
|
421
|
+
### Development Notes
|
|
422
|
+
|
|
423
|
+
- The app uses **Tailwind CSS v4** with CSS custom properties for theming.
|
|
424
|
+
- Database migrations are managed via **Drizzle Kit** (`drizzle-kit push` or manual migration scripts).
|
|
425
|
+
- The AI service uses **uv** for Python dependency management.
|
|
426
|
+
- All AI-related API calls are proxied through the Next.js backend to the FastAPI service.
|
|
427
|
+
|
|
428
|
+
---
|
|
429
|
+
|
|
430
|
+
## 📄 License
|
|
431
|
+
|
|
432
|
+
This project is released under the **MIT License**. See [LICENSE](LICENSE) for details.
|
|
433
|
+
|
|
434
|
+
---
|
|
435
|
+
|
|
436
|
+
<p align="center">
|
|
437
|
+
Built with ❤️ by the <a href="https://github.com/omniradiology">OmniRadiology</a> community
|
|
438
|
+
</p>
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { db } from "@/db";
|
|
3
|
+
import { aiConfigurations } from "@/db/schema";
|
|
4
|
+
import { eq } from "drizzle-orm";
|
|
5
|
+
|
|
6
|
+
export async function GET(req: Request) {
|
|
7
|
+
try {
|
|
8
|
+
const { searchParams } = new URL(req.url);
|
|
9
|
+
const mode = searchParams.get("mode");
|
|
10
|
+
const purpose = searchParams.get("purpose");
|
|
11
|
+
|
|
12
|
+
// Internal mode: return the active config WITH the real key (for server-side forwarding)
|
|
13
|
+
if (mode === "active_internal") {
|
|
14
|
+
const targetPurpose = purpose || "report_generation";
|
|
15
|
+
const all = db.select().from(aiConfigurations).where(eq(aiConfigurations.isActive, true)).all();
|
|
16
|
+
// Find config matching the requested purpose, fallback to any active
|
|
17
|
+
const match = all.find((c: any) => (c.purpose || 'report_generation') === targetPurpose) || all[0];
|
|
18
|
+
if (!match) {
|
|
19
|
+
return NextResponse.json({ error: "No active AI configuration found" }, { status: 404 });
|
|
20
|
+
}
|
|
21
|
+
return NextResponse.json(match);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Default: return all configs, optionally filtered by purpose
|
|
25
|
+
const configs = db.select().from(aiConfigurations).all();
|
|
26
|
+
if (purpose) {
|
|
27
|
+
const filtered = configs.filter((c: any) => (c.purpose || 'report_generation') === purpose);
|
|
28
|
+
return NextResponse.json(filtered);
|
|
29
|
+
}
|
|
30
|
+
return NextResponse.json(configs);
|
|
31
|
+
} catch (e) {
|
|
32
|
+
return NextResponse.json({ error: String(e) }, { status: 500 });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function POST(req: Request) {
|
|
37
|
+
try {
|
|
38
|
+
const body = await req.json();
|
|
39
|
+
|
|
40
|
+
// Auto-derive providerName from endpoint URL since we removed it from the UI
|
|
41
|
+
const url = (body.apiEndpointUrl || "").toLowerCase();
|
|
42
|
+
let providerName = "Custom API";
|
|
43
|
+
if (url.includes("googleapis.com") || url.includes("generativelanguage")) {
|
|
44
|
+
providerName = "Google Gemini";
|
|
45
|
+
} else if (url.includes("openrouter")) {
|
|
46
|
+
providerName = "OpenRouter";
|
|
47
|
+
} else if (url.includes("openai.com")) {
|
|
48
|
+
providerName = "OpenAI";
|
|
49
|
+
} else if (body.providerType === "ollama") {
|
|
50
|
+
providerName = "Ollama";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const configPurpose = body.purpose || "report_generation";
|
|
54
|
+
|
|
55
|
+
// Deactivate other configs with the same purpose
|
|
56
|
+
if (body.isActive) {
|
|
57
|
+
const allConfigs = db.select().from(aiConfigurations).all();
|
|
58
|
+
for (const c of allConfigs) {
|
|
59
|
+
if ((c.purpose || 'report_generation') === configPurpose && c.isActive) {
|
|
60
|
+
db.update(aiConfigurations).set({ isActive: false }).where(eq(aiConfigurations.id, c.id)).run();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Check if a config for this purpose already exists (upsert pattern — one config per purpose)
|
|
66
|
+
const allConfigs = db.select().from(aiConfigurations).all();
|
|
67
|
+
const existing = allConfigs.filter((c: any) => (c.purpose || 'report_generation') === configPurpose);
|
|
68
|
+
|
|
69
|
+
if (existing.length > 0) {
|
|
70
|
+
const updateData: any = {
|
|
71
|
+
providerType: body.providerType,
|
|
72
|
+
providerName,
|
|
73
|
+
apiEndpointUrl: body.apiEndpointUrl,
|
|
74
|
+
modelName: body.modelName,
|
|
75
|
+
isActive: body.isActive ?? true,
|
|
76
|
+
isVisionCapable: body.isVisionCapable ?? false,
|
|
77
|
+
maxTokens: body.maxTokens ?? 4096,
|
|
78
|
+
temperature: body.temperature ?? 0.3,
|
|
79
|
+
timeoutSeconds: body.timeoutSeconds ?? 120,
|
|
80
|
+
purpose: configPurpose,
|
|
81
|
+
updatedAt: new Date().toISOString(),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Only update the key if a new non-empty one was provided
|
|
85
|
+
if (body.apiSecretKey && body.apiSecretKey.trim() !== "") {
|
|
86
|
+
updateData.apiSecretKey = body.apiSecretKey;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Add langsmith fields for copilot
|
|
90
|
+
if (configPurpose === "copilot") {
|
|
91
|
+
if (body.langsmithApiKey !== undefined) updateData.langsmithApiKey = body.langsmithApiKey;
|
|
92
|
+
if (body.langsmithProject !== undefined) updateData.langsmithProject = body.langsmithProject;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
db.update(aiConfigurations)
|
|
96
|
+
.set(updateData)
|
|
97
|
+
.where(eq(aiConfigurations.id, existing[0].id))
|
|
98
|
+
.run();
|
|
99
|
+
|
|
100
|
+
return NextResponse.json({ success: true, updated: true });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Create new config
|
|
104
|
+
if (body.providerType !== "ollama" && (!body.apiSecretKey || body.apiSecretKey.trim() === "")) {
|
|
105
|
+
return NextResponse.json({ error: "API Secret Key is required for new configurations." }, { status: 400 });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
db.insert(aiConfigurations).values({
|
|
109
|
+
id: `config_${Date.now()}_${configPurpose}`,
|
|
110
|
+
providerType: body.providerType,
|
|
111
|
+
providerName,
|
|
112
|
+
apiEndpointUrl: body.apiEndpointUrl,
|
|
113
|
+
apiSecretKey: body.apiSecretKey || "",
|
|
114
|
+
modelName: body.modelName,
|
|
115
|
+
isActive: body.isActive ?? true,
|
|
116
|
+
isVisionCapable: body.isVisionCapable ?? false,
|
|
117
|
+
maxTokens: body.maxTokens ?? 4096,
|
|
118
|
+
temperature: body.temperature ?? 0.3,
|
|
119
|
+
timeoutSeconds: body.timeoutSeconds ?? 120,
|
|
120
|
+
purpose: configPurpose,
|
|
121
|
+
langsmithApiKey: body.langsmithApiKey || null,
|
|
122
|
+
langsmithProject: body.langsmithProject || null,
|
|
123
|
+
createdAt: new Date().toISOString(),
|
|
124
|
+
}).run();
|
|
125
|
+
|
|
126
|
+
return NextResponse.json({ success: true });
|
|
127
|
+
} catch (e) {
|
|
128
|
+
console.error("[AI Config] Error saving:", e);
|
|
129
|
+
return NextResponse.json({ error: String(e) }, { status: 500 });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { db } from "@/db";
|
|
3
|
+
import { aiConfigurations } from "@/db/schema";
|
|
4
|
+
import { eq } from "drizzle-orm";
|
|
5
|
+
|
|
6
|
+
export async function POST(req: Request) {
|
|
7
|
+
try {
|
|
8
|
+
// Fetch active internal config (unmasked key)
|
|
9
|
+
const active = db.select().from(aiConfigurations).where(eq(aiConfigurations.isActive, true)).all();
|
|
10
|
+
|
|
11
|
+
if (active.length === 0) {
|
|
12
|
+
return NextResponse.json({ success: false, error: "No active AI configuration found." });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const aiConfig = active[0];
|
|
16
|
+
|
|
17
|
+
// Forward to local python backend
|
|
18
|
+
const pythonEndpoint = "http://localhost:8001/test_ai_connection";
|
|
19
|
+
const payload = {
|
|
20
|
+
ai_config: {
|
|
21
|
+
providerType: aiConfig.providerType,
|
|
22
|
+
providerName: aiConfig.providerName,
|
|
23
|
+
apiEndpointUrl: aiConfig.apiEndpointUrl,
|
|
24
|
+
apiSecretKey: aiConfig.apiSecretKey,
|
|
25
|
+
modelName: aiConfig.modelName
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const response = await fetch(pythonEndpoint, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: {
|
|
32
|
+
"Content-Type": "application/json"
|
|
33
|
+
},
|
|
34
|
+
body: JSON.stringify(payload)
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const data = await response.json();
|
|
38
|
+
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
return NextResponse.json({ success: false, error: data.error || `HTTP ${response.status}` });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return NextResponse.json(data);
|
|
44
|
+
|
|
45
|
+
} catch (e) {
|
|
46
|
+
console.error("[AI Config Test] Error checking AI model connection:", e);
|
|
47
|
+
return NextResponse.json({ success: false, error: String(e) }, { status: 500 });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { db } from '@/db';
|
|
3
|
+
import { security, users, sessions } from '@/db/schema';
|
|
4
|
+
import { eq } from 'drizzle-orm';
|
|
5
|
+
import { generateSessionId } from '@/lib/auth';
|
|
6
|
+
import { cookies } from 'next/headers';
|
|
7
|
+
|
|
8
|
+
export async function GET(req: NextRequest) {
|
|
9
|
+
try {
|
|
10
|
+
// Check if app is actually unlocked
|
|
11
|
+
const secRow = db.select().from(security).where(eq(security.id, 1)).get();
|
|
12
|
+
if (!secRow || secRow.appLockEnabled !== false) {
|
|
13
|
+
// App is locked, redirect to login
|
|
14
|
+
const loginUrl = new URL('/login', req.url);
|
|
15
|
+
return NextResponse.redirect(loginUrl);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Find the user to auto-login as
|
|
19
|
+
let targetUser = null;
|
|
20
|
+
|
|
21
|
+
if (secRow.defaultUserId) {
|
|
22
|
+
const usersList = db.select().from(users).where(eq(users.id, secRow.defaultUserId)).all();
|
|
23
|
+
targetUser = usersList[0];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!targetUser) {
|
|
27
|
+
// Fallback to first admin
|
|
28
|
+
const adminUsers = db.select().from(users).where(eq(users.role, 'Admin')).all();
|
|
29
|
+
targetUser = adminUsers[0];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!targetUser) {
|
|
33
|
+
// No admin found, fall back to login
|
|
34
|
+
const loginUrl = new URL('/login', req.url);
|
|
35
|
+
return NextResponse.redirect(loginUrl);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Create a persistent session (30 days)
|
|
39
|
+
const sessionId = generateSessionId();
|
|
40
|
+
const expiresAt = Math.floor(Date.now() / 1000) + (60 * 60 * 24 * 30);
|
|
41
|
+
|
|
42
|
+
db.insert(sessions).values({
|
|
43
|
+
id: sessionId,
|
|
44
|
+
userId: targetUser.id,
|
|
45
|
+
expiresAt,
|
|
46
|
+
}).run();
|
|
47
|
+
|
|
48
|
+
// Set session cookie
|
|
49
|
+
const cookieStore = await cookies();
|
|
50
|
+
cookieStore.set('omnirad_session_id', sessionId, {
|
|
51
|
+
httpOnly: true,
|
|
52
|
+
secure: process.env.NODE_ENV === 'production',
|
|
53
|
+
sameSite: 'lax',
|
|
54
|
+
path: '/',
|
|
55
|
+
maxAge: 60 * 60 * 24 * 30,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Redirect to intended page or dashboard
|
|
59
|
+
const redirect = req.nextUrl.searchParams.get('redirect') || '/';
|
|
60
|
+
return NextResponse.redirect(new URL(redirect, req.url));
|
|
61
|
+
} catch (e: unknown) {
|
|
62
|
+
console.error('[Auto-Login] Error:', e);
|
|
63
|
+
// On any error, fall back to login page
|
|
64
|
+
return NextResponse.redirect(new URL('/login', req.url));
|
|
65
|
+
}
|
|
66
|
+
}
|