@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
package/lib/auth.ts ADDED
@@ -0,0 +1,22 @@
1
+ import bcrypt from "bcryptjs";
2
+
3
+ /**
4
+ * Hashes a plaintext password using bcrypt.
5
+ */
6
+ export async function hashPassword(password: string): Promise<string> {
7
+ return await bcrypt.hash(password, 10);
8
+ }
9
+
10
+ /**
11
+ * Verifies a plaintext password against a stored bcrypt hash.
12
+ */
13
+ export async function verifyPassword(password: string, hash: string): Promise<boolean> {
14
+ return await bcrypt.compare(password, hash);
15
+ }
16
+
17
+ /**
18
+ * Generates a secure random session ID.
19
+ */
20
+ export function generateSessionId(): string {
21
+ return crypto.randomUUID();
22
+ }
@@ -0,0 +1,94 @@
1
+ // ─── Viewer Action Executor ──────────────────────────────────────────────────
2
+ // Deterministic executor that processes AI viewer actions against the
3
+ // CopilotCornerstoneViewer ref in the correct order.
4
+
5
+ import type {
6
+ AIViewerAction,
7
+ CopilotViewerRef,
8
+ AnnotationAction,
9
+ SegmentationAction,
10
+ } from "@/types/copilot-viewer";
11
+ import {
12
+ sortViewerActions,
13
+ isNavigateAction,
14
+ isAnnotationAction,
15
+ isSegmentationAction,
16
+ isViewportAction,
17
+ isClearAction,
18
+ } from "./action-types";
19
+
20
+ /**
21
+ * Execute a list of AI viewer actions against the viewer ref.
22
+ * Actions are sorted by priority: clear → navigate → segment → annotate → viewport
23
+ * and executed sequentially with a small delay between groups.
24
+ */
25
+ export async function executeViewerActions(
26
+ actions: AIViewerAction[],
27
+ viewerRef: CopilotViewerRef | null,
28
+ ): Promise<void> {
29
+ if (!viewerRef || !actions || actions.length === 0) return;
30
+
31
+ const sorted = sortViewerActions(actions);
32
+
33
+ for (const action of sorted) {
34
+ try {
35
+ executeSingleAction(action, viewerRef);
36
+ // Small delay between actions for visual feedback
37
+ await sleep(50);
38
+ } catch (err) {
39
+ console.error("[ActionExecutor] Error executing action:", action, err);
40
+ }
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Execute a single viewer action.
46
+ */
47
+ function executeSingleAction(
48
+ action: AIViewerAction,
49
+ viewer: CopilotViewerRef,
50
+ ): void {
51
+ if (isClearAction(action)) {
52
+ switch (action.action) {
53
+ case "clear_ai_annotations":
54
+ case "clear_ai_segmentations":
55
+ case "clear_all_ai_findings":
56
+ viewer.clearAIFindings();
57
+ break;
58
+ }
59
+ return;
60
+ }
61
+
62
+ if (isNavigateAction(action)) {
63
+ viewer.jumpToSlice(action.slice);
64
+ return;
65
+ }
66
+
67
+ if (isSegmentationAction(action)) {
68
+ viewer.addSegmentation(action as SegmentationAction);
69
+ return;
70
+ }
71
+
72
+ if (isAnnotationAction(action)) {
73
+ viewer.addAnnotation(action as AnnotationAction);
74
+ return;
75
+ }
76
+
77
+ if (isViewportAction(action)) {
78
+ switch (action.action) {
79
+ case "zoom_to_region":
80
+ if (action.x !== undefined && action.y !== undefined && action.width !== undefined && action.height !== undefined) {
81
+ viewer.zoomToRegion(action.x, action.y, action.width, action.height);
82
+ }
83
+ break;
84
+ case "reset_viewport":
85
+ viewer.resetViewport();
86
+ break;
87
+ }
88
+ return;
89
+ }
90
+ }
91
+
92
+ function sleep(ms: number): Promise<void> {
93
+ return new Promise((resolve) => setTimeout(resolve, ms));
94
+ }
@@ -0,0 +1,72 @@
1
+ // ─── Copilot Viewer Action Types & Utilities ─────────────────────────────────
2
+ // Central definitions and type guards for all viewer actions.
3
+
4
+ import type {
5
+ AIViewerAction,
6
+ NavigateAction,
7
+ AnnotationAction,
8
+ SegmentationAction,
9
+ ViewportAction,
10
+ ClearAction,
11
+ } from "@/types/copilot-viewer";
12
+
13
+ // ── Type Guards ──────────────────────────────────────────────────────────────
14
+
15
+ export function isNavigateAction(a: AIViewerAction): a is NavigateAction {
16
+ return a.type === "navigate";
17
+ }
18
+
19
+ export function isAnnotationAction(a: AIViewerAction): a is AnnotationAction {
20
+ return a.type === "annotation";
21
+ }
22
+
23
+ export function isSegmentationAction(a: AIViewerAction): a is SegmentationAction {
24
+ return a.type === "segmentation";
25
+ }
26
+
27
+ export function isViewportAction(a: AIViewerAction): a is ViewportAction {
28
+ return a.type === "viewport";
29
+ }
30
+
31
+ export function isClearAction(a: AIViewerAction): a is ClearAction {
32
+ return a.type === "clear";
33
+ }
34
+
35
+ // ── Action Sorting ───────────────────────────────────────────────────────────
36
+ // Execution order: clear → navigate → segment → annotate → viewport
37
+
38
+ const ACTION_PRIORITY: Record<string, number> = {
39
+ clear: 0,
40
+ navigate: 1,
41
+ segmentation: 2,
42
+ annotation: 3,
43
+ viewport: 4,
44
+ };
45
+
46
+ export function sortViewerActions(actions: AIViewerAction[]): AIViewerAction[] {
47
+ return [...actions].sort(
48
+ (a, b) => (ACTION_PRIORITY[a.type] ?? 99) - (ACTION_PRIORITY[b.type] ?? 99)
49
+ );
50
+ }
51
+
52
+ // ── Default Styling ──────────────────────────────────────────────────────────
53
+
54
+ export const AI_ANNOTATION_DEFAULTS = {
55
+ color: "#ff4d4f", // Red for abnormal findings
56
+ normalColor: "#52c41a", // Green for normal findings
57
+ labelMode: "always" as const,
58
+ segmentationOpacity: 0.3,
59
+ segmentationColor: "#ff4d4f",
60
+ contourColor: "#ff6b6b",
61
+ contourWidth: 2,
62
+ };
63
+
64
+ // ── Label Formatting ─────────────────────────────────────────────────────────
65
+
66
+ export function formatAILabel(name: string, confidence?: number): string {
67
+ const base = name || "Finding";
68
+ if (confidence !== undefined && confidence > 0) {
69
+ return `${base} (AI) — ${Math.round(confidence * 100)}%`;
70
+ }
71
+ return `${base} (AI)`;
72
+ }
@@ -0,0 +1,84 @@
1
+ // ─── Coordinate Mapper ───────────────────────────────────────────────────────
2
+ // Maps between MedSAM3 pixel-space coordinates and the Cornerstone viewport.
3
+ // First implementation uses image pixel space which is sufficient for stack viewports.
4
+
5
+ export interface ImageDimensions {
6
+ width: number;
7
+ height: number;
8
+ }
9
+
10
+ /**
11
+ * Convert image pixel coordinates to viewport coordinates.
12
+ * For stack viewports with standard 2D slice rendering, image-space
13
+ * coordinates map 1:1 with Cornerstone image coordinates.
14
+ */
15
+ export function imageToViewportCoordinates(
16
+ imageX: number,
17
+ imageY: number,
18
+ imageDims: ImageDimensions,
19
+ viewportDims: ImageDimensions
20
+ ): { x: number; y: number } {
21
+ const scaleX = viewportDims.width / imageDims.width;
22
+ const scaleY = viewportDims.height / imageDims.height;
23
+ const scale = Math.min(scaleX, scaleY);
24
+
25
+ const offsetX = (viewportDims.width - imageDims.width * scale) / 2;
26
+ const offsetY = (viewportDims.height - imageDims.height * scale) / 2;
27
+
28
+ return {
29
+ x: imageX * scale + offsetX,
30
+ y: imageY * scale + offsetY,
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Convert viewport coordinates back to image pixel coordinates.
36
+ */
37
+ export function viewportToImageCoordinates(
38
+ viewportX: number,
39
+ viewportY: number,
40
+ imageDims: ImageDimensions,
41
+ viewportDims: ImageDimensions
42
+ ): { x: number; y: number } {
43
+ const scaleX = viewportDims.width / imageDims.width;
44
+ const scaleY = viewportDims.height / imageDims.height;
45
+ const scale = Math.min(scaleX, scaleY);
46
+
47
+ const offsetX = (viewportDims.width - imageDims.width * scale) / 2;
48
+ const offsetY = (viewportDims.height - imageDims.height * scale) / 2;
49
+
50
+ return {
51
+ x: (viewportX - offsetX) / scale,
52
+ y: (viewportY - offsetY) / scale,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Convert percentage-based coordinates (0-1 range) to pixel coordinates.
58
+ * Useful when an upstream vision model returns normalized bounding boxes.
59
+ */
60
+ export function percentToPixelCoordinates(
61
+ percentX: number,
62
+ percentY: number,
63
+ imageDims: ImageDimensions
64
+ ): { x: number; y: number } {
65
+ return {
66
+ x: percentX * imageDims.width,
67
+ y: percentY * imageDims.height,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Convert a percentage-based bounding box to pixel bbox.
73
+ */
74
+ export function percentBboxToPixelBbox(
75
+ bbox: [number, number, number, number],
76
+ imageDims: ImageDimensions
77
+ ): [number, number, number, number] {
78
+ return [
79
+ bbox[0] * imageDims.width,
80
+ bbox[1] * imageDims.height,
81
+ bbox[2] * imageDims.width,
82
+ bbox[3] * imageDims.height,
83
+ ];
84
+ }
@@ -0,0 +1,103 @@
1
+ import * as dicomParser from 'dicom-parser';
2
+
3
+ export async function extractUncompressedDicomFrame(file: File): Promise<string | null> {
4
+ try {
5
+ const arrayBuffer = await file.arrayBuffer();
6
+ const byteArray = new Uint8Array(arrayBuffer);
7
+ const dataSet = dicomParser.parseDicom(byteArray);
8
+
9
+ const transferSyntax = dataSet.string('x00020010')?.trim() || '';
10
+
11
+ // Uncompressed syntaxes (Implicit VR Little Endian, Explicit VR Little/Big Endian)
12
+ const uncompressedSyntaxes = ['1.2.840.10008.1.2', '1.2.840.10008.1.2.1', '1.2.840.10008.1.2.2'];
13
+
14
+ if (!uncompressedSyntaxes.includes(transferSyntax)) {
15
+ // Not supported by this simple extractor. Will fall back to Cornerstone Viewer.
16
+ return null;
17
+ }
18
+
19
+ const rows = dataSet.uint16('x00280010');
20
+ const columns = dataSet.uint16('x00280011');
21
+ const bitsAllocated = dataSet.uint16('x00280100');
22
+ const pixelRepresentation = dataSet.uint16('x00280103') || 0; // 0=unsigned, 1=signed
23
+
24
+ let windowCenter = dataSet.string('x00281050') ? parseFloat(dataSet.string('x00281050')!) : undefined;
25
+ let windowWidth = dataSet.string('x00281051') ? parseFloat(dataSet.string('x00281051')!) : undefined;
26
+
27
+ if (!rows || !columns || !bitsAllocated) return null;
28
+
29
+ const pixelDataElement = dataSet.elements.x7fe00010;
30
+ if (!pixelDataElement) return null;
31
+
32
+ // Extract raw bytes
33
+ const pixelBytes = byteArray.slice(pixelDataElement.dataOffset, pixelDataElement.dataOffset + pixelDataElement.length);
34
+
35
+ let minPixel = Infinity;
36
+ let maxPixel = -Infinity;
37
+ const pixelValues = new Float32Array(rows * columns);
38
+
39
+ if (bitsAllocated === 16) {
40
+ const dataView = new DataView(pixelBytes.buffer, pixelBytes.byteOffset, pixelBytes.byteLength);
41
+ for (let i = 0; i < rows * columns; i++) {
42
+ let val = pixelRepresentation === 1 ? dataView.getInt16(i * 2, true) : dataView.getUint16(i * 2, true);
43
+ pixelValues[i] = val;
44
+ if (val < minPixel) minPixel = val;
45
+ if (val > maxPixel) maxPixel = val;
46
+ }
47
+ } else if (bitsAllocated === 8) {
48
+ for (let i = 0; i < rows * columns; i++) {
49
+ let val = pixelBytes[i];
50
+ pixelValues[i] = val;
51
+ if (val < minPixel) minPixel = val;
52
+ if (val > maxPixel) maxPixel = val;
53
+ }
54
+ } else {
55
+ return null; // Unsupported bit depth
56
+ }
57
+
58
+ // Apply Window/Level if provided, otherwise auto-window to min/max
59
+ if (windowCenter === undefined || windowWidth === undefined) {
60
+ windowCenter = (maxPixel + minPixel) / 2;
61
+ windowWidth = maxPixel - minPixel;
62
+ }
63
+
64
+ const lowerBound = windowCenter - 0.5 - (windowWidth - 1) / 2;
65
+ const upperBound = windowCenter - 0.5 + (windowWidth - 1) / 2;
66
+
67
+ const canvas = document.createElement('canvas');
68
+ canvas.width = columns;
69
+ canvas.height = rows;
70
+ const ctx = canvas.getContext('2d');
71
+ if (!ctx) return null;
72
+
73
+ const imageData = ctx.createImageData(columns, rows);
74
+ const data = imageData.data;
75
+
76
+ for (let i = 0; i < pixelValues.length; i++) {
77
+ let val = pixelValues[i];
78
+
79
+ // Apply VOI LUT
80
+ let luminance = 0;
81
+ if (val <= lowerBound) {
82
+ luminance = 0;
83
+ } else if (val >= upperBound) {
84
+ luminance = 255;
85
+ } else {
86
+ luminance = Math.floor(((val - windowCenter - 0.5) / (windowWidth - 1) + 0.5) * 255);
87
+ }
88
+
89
+ const offset = i * 4;
90
+ data[offset] = luminance; // R
91
+ data[offset + 1] = luminance; // G
92
+ data[offset + 2] = luminance; // B
93
+ data[offset + 3] = 255; // A
94
+ }
95
+
96
+ ctx.putImageData(imageData, 0, 0);
97
+ return canvas.toDataURL('image/jpeg', 0.92);
98
+
99
+ } catch (e) {
100
+ console.error("Simple extractor failed:", e);
101
+ return null;
102
+ }
103
+ }
@@ -0,0 +1,111 @@
1
+ import * as dicomParser from 'dicom-parser';
2
+ import { DicomMetadata, DicomExtractionResult } from '@/types';
3
+
4
+ // Helper to safely extract and trim string values
5
+ function getString(dataSet: dicomParser.DataSet, tag: string): string | undefined {
6
+ const val = dataSet.string(tag);
7
+ if (val) return val.trim();
8
+ return undefined;
9
+ }
10
+
11
+ // Helper to safely extract numeric values
12
+ function getNumber(dataSet: dicomParser.DataSet, tag: string): number | undefined {
13
+ // Try uint16 first, which is common for rows/cols/bits
14
+ try {
15
+ const val = dataSet.uint16(tag);
16
+ if (val !== undefined && !isNaN(val)) return val;
17
+
18
+ const val32 = dataSet.uint32(tag);
19
+ if (val32 !== undefined && !isNaN(val32)) return val32;
20
+ } catch (e) {
21
+ // Tag might missing or wrong type
22
+ }
23
+ return undefined;
24
+ }
25
+
26
+ /**
27
+ * Formats the patient name from DICOM but does not filter out technical names.
28
+ */
29
+ function formatPatientName(raw: string | undefined): string | undefined {
30
+ if (!raw) return undefined;
31
+
32
+ // Replace DICOM ^ separator with spaces
33
+ let name = raw.replace(/\^/g, ' ').trim();
34
+ if (name.length === 0) return undefined;
35
+
36
+ // Proper case formatting: "DOE^JOHN" or "doe john" -> "Doe John"
37
+ // (Only applies to words separated by spaces. Things like Ultra_fast_Brain will become Ultra_fast_brain)
38
+ name = name
39
+ .split(/\s+/)
40
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
41
+ .join(' ');
42
+
43
+ return name;
44
+ }
45
+
46
+ /**
47
+ * Returns the patient ID as-is, trimmed.
48
+ */
49
+ function formatPatientId(raw: string | undefined): string | undefined {
50
+ if (!raw) return undefined;
51
+ const id = raw.trim();
52
+ if (id.length === 0) return undefined;
53
+ return id;
54
+ }
55
+
56
+ export async function parseDicomMetadata(file: File): Promise<DicomExtractionResult> {
57
+ try {
58
+ const arrayBuffer = await file.arrayBuffer();
59
+ const byteArray = new Uint8Array(arrayBuffer);
60
+
61
+ // Parse the DICOM file
62
+ // This throws if the file is not a valid DICOM file
63
+ const dataSet = dicomParser.parseDicom(byteArray);
64
+
65
+ // Extract raw tags
66
+ const rawPatientName = getString(dataSet, 'x00100010');
67
+ const rawPatientId = getString(dataSet, 'x00100020');
68
+
69
+ // Format names but allow all values
70
+ const cleanName = formatPatientName(rawPatientName);
71
+ const cleanId = formatPatientId(rawPatientId);
72
+
73
+ const metadata: DicomMetadata = {
74
+ patientName: cleanName,
75
+ patientId: cleanId,
76
+ patientBirthDate: getString(dataSet, 'x00100030'),
77
+ patientAge: getString(dataSet, 'x00101010'),
78
+ patientSex: getString(dataSet, 'x00100040'),
79
+ modality: getString(dataSet, 'x00080060'),
80
+ studyDate: getString(dataSet, 'x00080020'),
81
+ studyTime: getString(dataSet, 'x00080030'),
82
+ institutionName: getString(dataSet, 'x00080080'),
83
+ studyDescription: getString(dataSet, 'x00081030'),
84
+ seriesDescription: getString(dataSet, 'x0008103e'),
85
+ bodyPartExamined: getString(dataSet, 'x00180015'),
86
+ referringPhysicianName: getString(dataSet, 'x00080090'),
87
+ accessionNumber: getString(dataSet, 'x00080050'),
88
+
89
+ // Note: transfer syntax is technically in the File Meta Information (x00020010)
90
+ // dicom-parser handles this if Part 10 file format.
91
+ transferSyntaxUID: getString(dataSet, 'x00020010'),
92
+
93
+ rows: getNumber(dataSet, 'x00280010'),
94
+ columns: getNumber(dataSet, 'x00280011'),
95
+ bitsAllocated: getNumber(dataSet, 'x00280100'),
96
+ numberOfFrames: getNumber(dataSet, 'x00280008') || 1, // Default to 1 if missing
97
+ };
98
+
99
+ return {
100
+ success: true,
101
+ metadata
102
+ };
103
+ } catch (error: any) {
104
+ console.error('DICOM parsing error:', error);
105
+ return {
106
+ success: false,
107
+ error: error?.message || 'File does not appear to be a valid DICOM file.'
108
+ };
109
+ }
110
+ }
111
+
@@ -0,0 +1,25 @@
1
+ import { Client } from "fhir-kit-client";
2
+
3
+ export function createFhirClient(
4
+ baseUrl: string,
5
+ authType: string,
6
+ bearerToken?: string,
7
+ clientId?: string,
8
+ clientSecret?: string
9
+ ): Client {
10
+ const config: any = {
11
+ baseUrl: baseUrl
12
+ };
13
+
14
+ if (authType === "bearer" && bearerToken) {
15
+ config.bearerToken = bearerToken;
16
+ } else if (authType === "basic" && clientId && clientSecret) {
17
+ // According to fhir-kit-client, for basic auth we can pass custom headers
18
+ const encoded = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
19
+ config.customHeaders = {
20
+ Authorization: `Basic ${encoded}`
21
+ };
22
+ }
23
+
24
+ return new Client(config);
25
+ }
@@ -0,0 +1,21 @@
1
+ export const FHIR_SYSTEMS = {
2
+ LOINC: "http://loinc.org",
3
+ HL7_V2_0074: "http://terminology.hl7.org/CodeSystem/v2-0074",
4
+ OMNIRAD_REPORT_ID: "urn:oid:1.3.6.1.4.1.59626.1.1", // Or another domain-specific URL
5
+ OMNIRAD_PATIENT_ID: "urn:oid:1.3.6.1.4.1.59626.1.2",
6
+ OMNIRAD_STUDY_UID: "urn:ietf:rfc:3986" // For DICOM UIDs like urn:oid:...
7
+ };
8
+
9
+ export const DEFAULT_DIAGNOSTIC_REPORT_CODE = {
10
+ system: FHIR_SYSTEMS.LOINC,
11
+ code: "18748-4",
12
+ display: "Diagnostic imaging study"
13
+ };
14
+
15
+ export const RADIOLOGY_CATEGORY = {
16
+ system: FHIR_SYSTEMS.HL7_V2_0074,
17
+ code: "RAD",
18
+ display: "Radiology"
19
+ };
20
+
21
+ export const FHIR_CONTENT_TYPE = "application/fhir+json";
@@ -0,0 +1,88 @@
1
+ /// <reference types="fhir" />
2
+ import { DiagnosticReportInput } from "../../types/fhir";
3
+ import { FHIR_SYSTEMS, DEFAULT_DIAGNOSTIC_REPORT_CODE, RADIOLOGY_CATEGORY } from "./constants";
4
+ import { sanitizeId } from "./helpers";
5
+
6
+ export function toFhirDiagnosticReport({
7
+ report,
8
+ reportData,
9
+ patient,
10
+ pdfBase64,
11
+ serviceRequestId
12
+ }: DiagnosticReportInput): fhir4.DiagnosticReport {
13
+ let status: fhir4.DiagnosticReport["status"] = "unknown";
14
+ const pStatus = report.reportStatus?.toLowerCase();
15
+ if (pStatus === "pending") status = "preliminary";
16
+ else if (pStatus === "approved" || pStatus === "final") status = "final";
17
+ else if (pStatus === "rejected") status = "cancelled";
18
+
19
+ const identifiers = [];
20
+ if (reportData?.report_header?.report_id) {
21
+ identifiers.push({
22
+ system: FHIR_SYSTEMS.OMNIRAD_REPORT_ID,
23
+ value: reportData.report_header.report_id
24
+ });
25
+ } else {
26
+ identifiers.push({
27
+ system: FHIR_SYSTEMS.OMNIRAD_REPORT_ID,
28
+ value: report.id
29
+ });
30
+ }
31
+
32
+ const effectiveDateTime = reportData?.report_header?.report_date || report.createdAt;
33
+ let issued = reportData?.report_footer?.approved_at || report.createdAt;
34
+
35
+ const conclusion = reportData?.impression ? reportData.impression.join("\n") : undefined;
36
+
37
+ const resource: fhir4.DiagnosticReport = {
38
+ resourceType: "DiagnosticReport",
39
+ id: sanitizeId(report.id),
40
+ identifier: identifiers,
41
+ status,
42
+ category: [
43
+ {
44
+ coding: [RADIOLOGY_CATEGORY],
45
+ text: RADIOLOGY_CATEGORY.display
46
+ }
47
+ ],
48
+ code: {
49
+ coding: [DEFAULT_DIAGNOSTIC_REPORT_CODE],
50
+ text: reportData?.study?.examination || `${report.modality || "Imaging"} report`
51
+ },
52
+ subject: {
53
+ reference: patient ? `Patient/${sanitizeId(patient.id)}` : `Patient/${sanitizeId(report.patientId || "unknown")}`,
54
+ display: patient?.patientName || report.patientName || undefined
55
+ },
56
+ effectiveDateTime: effectiveDateTime ? new Date(effectiveDateTime).toISOString() : undefined,
57
+ issued: issued ? new Date(issued).toISOString() : undefined,
58
+ conclusion
59
+ };
60
+
61
+ if (report.pacsStudyUid || report.id) {
62
+ resource.imagingStudy = [
63
+ {
64
+ reference: `ImagingStudy/${sanitizeId(report.pacsStudyUid || report.id)}`
65
+ }
66
+ ];
67
+ }
68
+
69
+ if (serviceRequestId) {
70
+ resource.basedOn = [
71
+ {
72
+ reference: `ServiceRequest/${sanitizeId(serviceRequestId)}`
73
+ }
74
+ ];
75
+ }
76
+
77
+ if (pdfBase64) {
78
+ resource.presentedForm = [
79
+ {
80
+ contentType: "application/pdf",
81
+ title: "OmniRad Radiology Report",
82
+ data: pdfBase64
83
+ }
84
+ ];
85
+ }
86
+
87
+ return resource;
88
+ }