@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,73 @@
1
+ /// <reference types="fhir" />
2
+ import { NextResponse, NextRequest } from "next/server";
3
+ import { FHIR_CONTENT_TYPE } from "./constants";
4
+ import { db } from "@/db";
5
+ import { fhirIntegrationConfig } from "@/db/schema";
6
+ import { eq } from "drizzle-orm";
7
+ import * as bcrypt from "bcryptjs";
8
+
9
+ export function operationOutcome(
10
+ code: fhir4.OperationOutcomeIssue["code"],
11
+ diagnostics: string,
12
+ severity: fhir4.OperationOutcomeIssue["severity"] = "error"
13
+ ): fhir4.OperationOutcome {
14
+ return {
15
+ resourceType: "OperationOutcome",
16
+ issue: [
17
+ {
18
+ severity,
19
+ code,
20
+ diagnostics
21
+ }
22
+ ]
23
+ };
24
+ }
25
+
26
+ export function sanitizeId(id: string | null | undefined): string {
27
+ if (!id) return "unknown";
28
+ // FHIR IDs must be [A-Za-z0-9\-\.]{1,64}
29
+ return id.replace(/[^A-Za-z0-9\-\.]/g, "-").substring(0, 64);
30
+ }
31
+
32
+ export function fhirResponse(resource: any, status: number = 200) {
33
+ return NextResponse.json(resource, {
34
+ status,
35
+ headers: { "Content-Type": FHIR_CONTENT_TYPE }
36
+ });
37
+ }
38
+
39
+ export function fhirErrorResponse(code: fhir4.OperationOutcomeIssue["code"], message: string, status: number) {
40
+ return fhirResponse(operationOutcome(code, message), status);
41
+ }
42
+
43
+ export async function requireFhirAccess(request: NextRequest, scope: "read" | "write" = "read"): Promise<NextResponse | null> {
44
+ const config = db.select().from(fhirIntegrationConfig).where(eq(fhirIntegrationConfig.id, 1)).get();
45
+
46
+ if (!config || !config.enabled) {
47
+ return fhirErrorResponse("security", "FHIR API is disabled", 403);
48
+ }
49
+
50
+ if (scope === "read" && !config.outboundReadEnabled) {
51
+ return fhirErrorResponse("security", "Outbound FHIR reads are disabled", 403);
52
+ }
53
+
54
+ if (scope === "write" && !config.inboundServiceRequestEnabled) {
55
+ return fhirErrorResponse("security", "Inbound FHIR orders are disabled", 403);
56
+ }
57
+
58
+ if (config.authMode === "bearer_token" && config.apiTokenHash) {
59
+ const authHeader = request.headers.get("authorization");
60
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
61
+ return fhirErrorResponse("security", "Missing or invalid Authorization header", 401);
62
+ }
63
+
64
+ const token = authHeader.split(" ")[1];
65
+ const isValid = await bcrypt.compare(token, config.apiTokenHash);
66
+
67
+ if (!isValid) {
68
+ return fhirErrorResponse("security", "Invalid bearer token", 401);
69
+ }
70
+ }
71
+
72
+ return null;
73
+ }
@@ -0,0 +1,49 @@
1
+ /// <reference types="fhir" />
2
+ import { ImagingStudyInput } from "../../types/fhir";
3
+ import { FHIR_SYSTEMS } from "./constants";
4
+ import { sanitizeId } from "./helpers";
5
+
6
+ export function toFhirImagingStudy({
7
+ report,
8
+ reportData,
9
+ patient
10
+ }: ImagingStudyInput): fhir4.ImagingStudy {
11
+ const studyUid = report.pacsStudyUid || reportData?.pacs_info?.study_uid;
12
+ const seriesUid = report.pacsSeriesUid || reportData?.pacs_info?.series_uid;
13
+
14
+ const identifiers = [];
15
+ if (studyUid) {
16
+ identifiers.push({
17
+ system: FHIR_SYSTEMS.OMNIRAD_STUDY_UID,
18
+ value: `urn:oid:${studyUid}`
19
+ });
20
+ }
21
+
22
+ const series = [];
23
+ if (seriesUid) {
24
+ const modality = report.modality || reportData?.study?.modality;
25
+ series.push({
26
+ uid: seriesUid,
27
+ modality: {
28
+ system: "http://dicom.nema.org/resources/ontology/DCM",
29
+ code: modality || "UNKNOWN"
30
+ }
31
+ });
32
+ }
33
+
34
+ const started = reportData?.report_header?.report_date || report.createdAt;
35
+
36
+ return {
37
+ resourceType: "ImagingStudy",
38
+ id: sanitizeId(studyUid || report.id),
39
+ identifier: identifiers.length > 0 ? identifiers : undefined,
40
+ status: studyUid ? "available" : "unknown",
41
+ subject: {
42
+ reference: patient ? `Patient/${sanitizeId(patient.id)}` : `Patient/${sanitizeId(report.patientId || "unknown")}`,
43
+ display: patient?.patientName || report.patientName || undefined
44
+ },
45
+ started: started ? new Date(started).toISOString() : undefined,
46
+ description: reportData?.study?.examination || undefined,
47
+ series: series.length > 0 ? series : undefined
48
+ };
49
+ }
@@ -0,0 +1,55 @@
1
+ /// <reference types="fhir" />
2
+ import { Patient } from "../../types/index";
3
+ import { FHIR_SYSTEMS } from "./constants";
4
+ import { sanitizeId } from "./helpers";
5
+
6
+ export function toFhirPatient(patient: Patient): fhir4.Patient {
7
+ const names: fhir4.HumanName[] = [];
8
+ if (patient.patientName) {
9
+ const parts = patient.patientName.trim().split(/\s+/);
10
+ const family = parts.length > 1 ? parts.pop() || "" : "";
11
+ const given = parts;
12
+ names.push({
13
+ text: patient.patientName,
14
+ ...(family ? { family } : {}),
15
+ ...(given.length > 0 ? { given } : {})
16
+ });
17
+ }
18
+
19
+ let gender: fhir4.Patient["gender"] = "unknown";
20
+ const pGender = patient.gender?.toLowerCase() || "";
21
+ if (["male", "m"].includes(pGender)) gender = "male";
22
+ else if (["female", "f"].includes(pGender)) gender = "female";
23
+ else if (["other"].includes(pGender)) gender = "other";
24
+
25
+ const telecom: fhir4.ContactPoint[] = [];
26
+ if (patient.mobile) {
27
+ telecom.push({ system: "phone", value: patient.mobile });
28
+ }
29
+ if (patient.contactInfo && patient.contactInfo !== patient.mobile) {
30
+ if (patient.contactInfo.includes("@")) {
31
+ telecom.push({ system: "email", value: patient.contactInfo });
32
+ } else {
33
+ telecom.push({ system: "phone", value: patient.contactInfo });
34
+ }
35
+ }
36
+
37
+ const identifiers = [];
38
+ if (patient.patientIdNumber) {
39
+ identifiers.push({
40
+ system: FHIR_SYSTEMS.OMNIRAD_PATIENT_ID,
41
+ value: patient.patientIdNumber
42
+ });
43
+ }
44
+
45
+ return {
46
+ resourceType: "Patient",
47
+ id: sanitizeId(patient.id),
48
+ identifier: identifiers.length > 0 ? identifiers : undefined,
49
+ name: names.length > 0 ? names : undefined,
50
+ gender,
51
+ birthDate: patient.dob || undefined,
52
+ telecom: telecom.length > 0 ? telecom : undefined,
53
+ address: patient.address ? [{ text: patient.address }] : undefined
54
+ };
55
+ }
@@ -0,0 +1,85 @@
1
+ /// <reference types="fhir" />
2
+ import { WorklistOrder, OmniRadWorklistOrderInput } from "../../types/fhir";
3
+ import { sanitizeId } from "./helpers";
4
+
5
+ function generateUUID() {
6
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
7
+ var r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
8
+ return v.toString(16);
9
+ });
10
+ }
11
+
12
+ export function parseInboundServiceRequest(resource: fhir4.ServiceRequest): OmniRadWorklistOrderInput {
13
+ if (resource.resourceType !== "ServiceRequest") {
14
+ throw new Error("Invalid resourceType: expected ServiceRequest");
15
+ }
16
+
17
+ // priority mapping
18
+ let urgency = "Routine";
19
+ if (resource.priority === "urgent" || resource.priority === "asap") urgency = "Urgent";
20
+ else if (resource.priority === "stat") urgency = "Critical";
21
+
22
+ let patientId = null;
23
+ let patientName = "Unknown";
24
+ let patientIdentifier = "";
25
+
26
+ if (resource.subject) {
27
+ if (resource.subject.reference && resource.subject.reference.startsWith("Patient/")) {
28
+ patientId = resource.subject.reference.replace("Patient/", "");
29
+ }
30
+ if (resource.subject.display) {
31
+ patientName = resource.subject.display;
32
+ }
33
+ }
34
+
35
+ let requestedProcedure = "";
36
+ if (resource.code?.text) {
37
+ requestedProcedure = resource.code.text;
38
+ } else if (resource.code?.coding && resource.code.coding.length > 0) {
39
+ requestedProcedure = resource.code.coding[0].display || resource.code.coding[0].code || "";
40
+ }
41
+
42
+ let reason = "";
43
+ if (resource.reasonCode && resource.reasonCode.length > 0) {
44
+ reason = resource.reasonCode[0].text || resource.reasonCode[0].coding?.[0]?.display || "";
45
+ }
46
+
47
+ return {
48
+ id: generateUUID(),
49
+ sourceSystem: "FHIR",
50
+ fhirServiceRequestId: resource.id || "",
51
+ patientId,
52
+ patientName,
53
+ patientIdentifier,
54
+ status: resource.status || "active",
55
+ intent: resource.intent || "order",
56
+ priority: resource.priority || "routine",
57
+ urgency,
58
+ modality: null,
59
+ requestedProcedure,
60
+ reason,
61
+ authoredOn: resource.authoredOn || new Date().toISOString(),
62
+ requesterDisplay: resource.requester?.display || null,
63
+ rawFhir: JSON.stringify(resource),
64
+ createdAt: new Date().toISOString(),
65
+ updatedAt: null
66
+ };
67
+ }
68
+
69
+ export function toFhirServiceRequest(order: WorklistOrder): fhir4.ServiceRequest {
70
+ return {
71
+ resourceType: "ServiceRequest",
72
+ id: sanitizeId(order.fhirServiceRequestId || order.id),
73
+ status: (order.status as fhir4.ServiceRequest["status"]) || "active",
74
+ intent: (order.intent as fhir4.ServiceRequest["intent"]) || "order",
75
+ priority: (order.priority as fhir4.ServiceRequest["priority"]) || "routine",
76
+ code: order.requestedProcedure ? { text: order.requestedProcedure } : undefined,
77
+ subject: {
78
+ reference: order.patientId ? `Patient/${sanitizeId(order.patientId)}` : undefined,
79
+ display: order.patientName || undefined
80
+ },
81
+ authoredOn: order.authoredOn || undefined,
82
+ reasonCode: order.reason ? [{ text: order.reason }] : undefined,
83
+ requester: order.requesterDisplay ? { display: order.requesterDisplay } : undefined
84
+ };
85
+ }
package/lib/fhir.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from "./fhir/constants";
2
+ export * from "./fhir/helpers";
3
+ export * from "./fhir/patient";
4
+ export * from "./fhir/diagnostic-report";
5
+ export * from "./fhir/imaging-study";
6
+ export * from "./fhir/service-request";
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Parses and formats a DICOM Patient Name (PN) VR.
3
+ * Usually in the format: "FamilyName^GivenName^MiddleName^NamePrefix^NameSuffix"
4
+ * DICOMweb json may already extract it as { Alphabetic: "DOE^JOHN" }
5
+ */
6
+ export function formatPatientName(pnString: string | any): string {
7
+ if (!pnString) return "Unknown";
8
+ let raw = "";
9
+
10
+ if (typeof pnString === "string") {
11
+ raw = pnString;
12
+ } else if (pnString.Alphabetic) {
13
+ raw = pnString.Alphabetic;
14
+ } else {
15
+ return "Unknown";
16
+ }
17
+
18
+ // Replace carets with spaces, keeping names proper-cased roughly
19
+ const parts = raw.split("^").map(p => p.trim()).filter(Boolean);
20
+ if (parts.length === 0) return "Unknown";
21
+
22
+ // Usually family name is first in DICOM, given name second.
23
+ // Example: DOE^JOHN => JOHN DOE
24
+ if (parts.length >= 2) {
25
+ return `${parts[1]} ${parts[0]}`.replace(/\b\w/g, l => l.toUpperCase());
26
+ }
27
+
28
+ // If only one part or unusual formatting
29
+ return parts[0].replace(/\b\w/g, l => l.toUpperCase());
30
+ }
31
+
32
+ /**
33
+ * Formats a DICOM Date (DA) VR.
34
+ * Format is typically "YYYYMMDD".
35
+ */
36
+ export function formatDicomDate(daString?: string): string {
37
+ if (!daString || daString.length !== 8) return daString || "Unknown Date";
38
+ const year = daString.slice(0, 4);
39
+ const month = daString.slice(4, 6);
40
+ const day = daString.slice(6, 8);
41
+ return `${year}-${month}-${day}`;
42
+ }
43
+
44
+ /**
45
+ * Formats a DICOM Time (TM) VR.
46
+ * Format is typically "HHMMSS.FFFFFF" or "HHMMSS".
47
+ */
48
+ export function formatDicomTime(tmString?: string): string {
49
+ if (!tmString || tmString.length < 6) return tmString || "Unknown Time";
50
+ const hours = tmString.slice(0, 2);
51
+ const minutes = tmString.slice(2, 4);
52
+ const seconds = tmString.slice(4, 6);
53
+ return `${hours}:${minutes}:${seconds}`;
54
+ }
55
+
56
+ /**
57
+ * Parses a DICOM Age String (AS).
58
+ * Format is e.g., "045Y", "006M", "014D".
59
+ */
60
+ export function parseDicomAge(asString?: string): number | null {
61
+ if (!asString) return null;
62
+ const match = asString.match(/^(\d{3})([YMWD])$/);
63
+ if (!match) return null;
64
+
65
+ const value = parseInt(match[1], 10);
66
+ const unit = match[2];
67
+
68
+ if (unit === 'Y') return value;
69
+ if (unit === 'M') return Math.floor(value / 12); // Approximate years from months
70
+
71
+ return 0; // Infant/Days
72
+ }
@@ -0,0 +1,72 @@
1
+ import { DicomStudy, DicomSeries, DicomInstance } from "@/types/pacs";
2
+ import { formatPatientName, formatDicomDate, formatDicomTime } from "./dicom-utils";
3
+
4
+ /**
5
+ * Searches for studies using our Next.js API proxy
6
+ */
7
+ export async function searchStudies(filters: Record<string, string>): Promise<DicomStudy[]> {
8
+ const searchParams = new URLSearchParams(filters);
9
+ searchParams.append('includefield', '00081030,00100030,00101010,00100040,00080061'); // Ensure StudyDescription, Age, Sex, Modalities are returned
10
+ const res = await fetch(`/api/pacs/qido/studies?${searchParams.toString()}`);
11
+ if (!res.ok) throw new Error(await res.text());
12
+
13
+ const data = await res.json();
14
+ return data.map((item: any) => ({
15
+ id: item["0020000D"]?.Value?.[0] || "",
16
+ studyInstanceUid: item["0020000D"]?.Value?.[0] || "",
17
+ patientName: formatPatientName(item["00100010"]?.Value?.[0]?.Alphabetic || item["00100010"]?.Value?.[0] || "Unknown"),
18
+ patientId: item["00100020"]?.Value?.[0] || "Unknown",
19
+ patientBirthDate: item["00100030"]?.Value?.[0] || "",
20
+ patientAge: item["00101010"]?.Value?.[0] || "",
21
+ patientSex: item["00100040"]?.Value?.[0] || "",
22
+ studyDate: formatDicomDate(item["00080020"]?.Value?.[0]),
23
+ studyTime: formatDicomTime(item["00080030"]?.Value?.[0]),
24
+ accessionNumber: item["00080050"]?.Value?.[0] || "",
25
+ studyDescription: item["00081030"]?.Value?.[0] || "No Description",
26
+ modalitiesInStudy: item["00080061"]?.Value || [],
27
+ numberOfStudyRelatedSeries: item["00201206"]?.Value?.[0] || 0,
28
+ numberOfStudyRelatedInstances: item["00201208"]?.Value?.[0] || 0,
29
+ }));
30
+ }
31
+
32
+ /**
33
+ * Search series within a study
34
+ */
35
+ export async function searchSeries(studyUid: string): Promise<DicomSeries[]> {
36
+ const res = await fetch(`/api/pacs/qido/series?studyUid=${studyUid}&includefield=0008103E`); // Ensure SeriesDescription is returned
37
+ if (!res.ok) throw new Error(await res.text());
38
+
39
+ const data = await res.json();
40
+ return data.map((item: any) => ({
41
+ studyInstanceUid: studyUid,
42
+ seriesInstanceUid: item["0020000E"]?.Value?.[0] || "",
43
+ seriesNumber: item["00200011"]?.Value?.[0] || 0,
44
+ modality: item["00080060"]?.Value?.[0] || "Unknown",
45
+ seriesDescription: item["0008103E"]?.Value?.[0] || "No Description",
46
+ numberOfSeriesRelatedInstances: item["00201209"]?.Value?.[0] || 0,
47
+ }));
48
+ }
49
+
50
+ /**
51
+ * Fetch a single instance Rendered JPEG as an object URL
52
+ */
53
+ export async function fetchRenderedJpegUrl(studyUid: string, seriesUid: string, instanceUid: string, frame?: number): Promise<string> {
54
+ let url = `/api/pacs/wado/render?studyUid=${studyUid}&seriesUid=${seriesUid}&instanceUid=${instanceUid}`;
55
+ if (frame !== undefined) {
56
+ url += `&frame=${frame}`;
57
+ }
58
+ const res = await fetch(url);
59
+ if (!res.ok) throw new Error("Failed to fetch rendered image");
60
+
61
+ const blob = await res.blob();
62
+ return URL.createObjectURL(blob);
63
+ }
64
+
65
+ /**
66
+ * Fetch study metadata
67
+ */
68
+ export async function fetchStudyMetadata(studyUid: string): Promise<any> {
69
+ const res = await fetch(`/api/pacs/metadata?studyUid=${studyUid}`);
70
+ if (!res.ok) throw new Error(await res.text());
71
+ return res.json();
72
+ }
@@ -0,0 +1,37 @@
1
+ import { db } from "@/db";
2
+ import { config } from "@/db/schema";
3
+ import { eq } from "drizzle-orm";
4
+
5
+ export async function getPacsConfigServer() {
6
+ const row = db.select().from(config).where(eq(config.id, 1)).get();
7
+ if (!row) return null;
8
+
9
+ return {
10
+ pacsOrthancUrl: row.pacsOrthancUrl?.replace(/\/$/, "") || "",
11
+ pacsAuthType: row.pacsAuthType || "none",
12
+ pacsUsername: row.pacsUsername || "",
13
+ pacsPassword: row.pacsPassword || "",
14
+ pacsBearerToken: row.pacsBearerToken || "",
15
+ pacsAeTitle: row.pacsAeTitle || "",
16
+ };
17
+ }
18
+
19
+ export async function getOrthancHeaders() {
20
+ const pConfig = await getPacsConfigServer();
21
+ if (!pConfig || !pConfig.pacsOrthancUrl) {
22
+ throw new Error("Orthanc URL not configured");
23
+ }
24
+
25
+ const headers: Record<string, string> = {
26
+ "Accept": "application/json"
27
+ };
28
+
29
+ if (pConfig.pacsAuthType === "basic" && pConfig.pacsUsername && pConfig.pacsPassword) {
30
+ const authBase64 = Buffer.from(`${pConfig.pacsUsername}:${pConfig.pacsPassword}`).toString('base64');
31
+ headers["Authorization"] = `Basic ${authBase64}`;
32
+ } else if (pConfig.pacsAuthType === "bearer" && pConfig.pacsBearerToken) {
33
+ headers["Authorization"] = `Bearer ${pConfig.pacsBearerToken}`;
34
+ }
35
+
36
+ return { headers, baseUrl: pConfig.pacsOrthancUrl };
37
+ }
@@ -0,0 +1,25 @@
1
+ import { randomUUID } from "crypto";
2
+
3
+ import { Patient } from "@/types";
4
+
5
+ // Ensure the local patients table has parity with existing reports
6
+ export async function ensurePatientsMigrated() {
7
+ try {
8
+ // We do this by hitting an API route because server components / client components
9
+ // might not have direct SQLite access if we are running edge / separating concerns.
10
+ // Actually, we should just call an API route that does this so it accesses the DB securely.
11
+ const res = await fetch('/api/patients/merge', {
12
+ method: 'POST',
13
+ headers: { 'Content-Type': 'application/json' },
14
+ body: JSON.stringify({ action: "auto-migrate" }),
15
+ });
16
+ if (res.ok) {
17
+ const data = await res.json();
18
+ if (data.migrated > 0) {
19
+ console.log(`[OmniRad] Migrated ${data.migrated} local reports to patient records.`);
20
+ }
21
+ }
22
+ } catch (e) {
23
+ console.warn("[OmniRad] Failed to auto-migrate patients:", e);
24
+ }
25
+ }
@@ -0,0 +1,119 @@
1
+ import { ReportData } from "@/types";
2
+ import { generateReportHtml } from "@/lib/reportHtmlGenerator";
3
+
4
+ /**
5
+ * PDF Generation Helper - IFRAME ISOLATION APPROACH
6
+ *
7
+ * The root cause of blank PDFs: Tailwind CSS v4 injects oklab() color functions
8
+ * into computed styles globally. html2canvas traverses the entire DOM including
9
+ * inherited/computed styles and silently fails when it encounters oklab().
10
+ *
11
+ * Solution: Render the report HTML inside a hidden IFRAME. The iframe has its own
12
+ * document context, completely isolated from the parent page's Tailwind styles.
13
+ * This is exactly how professional PDF libraries work.
14
+ *
15
+ * Flow:
16
+ * 1. Create a hidden iframe
17
+ * 2. Write our clean HTML (pure inline styles) into the iframe's document
18
+ * 3. Point html2pdf at the iframe's body content
19
+ * 4. html2canvas only sees the iframe's clean DOM - no oklab, no Tailwind
20
+ * 5. Clean up
21
+ */
22
+
23
+ export async function generatePDF(report: ReportData, filename: string, template: 'standard' | 'modern' | 'minimal' = 'standard', logoUrl?: string) {
24
+ if (!report) {
25
+ alert('Invalid report data.');
26
+ return;
27
+ }
28
+
29
+ // Import html2pdf dynamically to avoid SSR issues
30
+ // @ts-ignore
31
+ const html2pdfModule = await import('html2pdf.js');
32
+ const html2pdf = html2pdfModule.default || html2pdfModule;
33
+
34
+ // 1. Generate the Clean HTML (pure inline styles, no Tailwind)
35
+ const htmlContent = generateReportHtml(report, template, logoUrl);
36
+
37
+ // 2. Create a hidden iframe for COMPLETE CSS isolation
38
+ const iframe = document.createElement('iframe');
39
+ iframe.style.position = 'fixed';
40
+ iframe.style.left = '-10000px';
41
+ iframe.style.top = '0';
42
+ iframe.style.width = '210mm';
43
+ iframe.style.height = '297mm';
44
+ iframe.style.border = 'none';
45
+ iframe.style.opacity = '1'; // Must be visible for html2canvas
46
+ document.body.appendChild(iframe);
47
+
48
+ // 3. Write clean HTML into the iframe's document
49
+ const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
50
+ if (!iframeDoc) {
51
+ console.error('Could not access iframe document');
52
+ document.body.removeChild(iframe);
53
+ alert('PDF generation failed: Could not create isolated render context.');
54
+ return;
55
+ }
56
+
57
+ iframeDoc.open();
58
+ iframeDoc.write(`
59
+ <!DOCTYPE html>
60
+ <html>
61
+ <head>
62
+ <meta charset="UTF-8">
63
+ <style>
64
+ * { margin: 0; padding: 0; box-sizing: border-box; }
65
+ body {
66
+ background: #ffffff;
67
+ color: #000000;
68
+ font-family: Arial, Helvetica, sans-serif;
69
+ -webkit-print-color-adjust: exact;
70
+ print-color-adjust: exact;
71
+ }
72
+ </style>
73
+ </head>
74
+ <body>
75
+ ${htmlContent}
76
+ </body>
77
+ </html>
78
+ `);
79
+ iframeDoc.close();
80
+
81
+ // 4. Wait for the iframe content to fully render (images, fonts, layout)
82
+ await new Promise(resolve => setTimeout(resolve, 800));
83
+
84
+ // 5. Get the content element from inside the iframe
85
+ const contentElement = iframeDoc.body.firstElementChild as HTMLElement || iframeDoc.body;
86
+
87
+ // 6. Configure PDF Options - compact margin for single page fit
88
+ const opt = {
89
+ margin: [5, 5, 5, 5] as [number, number, number, number],
90
+ filename: filename,
91
+ image: { type: 'jpeg' as const, quality: 0.98 },
92
+ html2canvas: {
93
+ scale: 2,
94
+ useCORS: true,
95
+ logging: true, // Enable to debug in console
96
+ letterRendering: true,
97
+ allowTaint: true,
98
+ backgroundColor: '#ffffff',
99
+ // CRITICAL: Tell html2canvas to use the iframe's window context
100
+ windowWidth: contentElement.scrollWidth || 794, // ~210mm in px
101
+ windowHeight: contentElement.scrollHeight || 1123, // ~297mm in px
102
+ },
103
+ jsPDF: { unit: 'mm' as const, format: 'a4' as const, orientation: 'portrait' as const },
104
+ pagebreak: { mode: ['avoid-all'] },
105
+ };
106
+
107
+ // 7. Generate and Save
108
+ try {
109
+ await html2pdf().set(opt).from(contentElement).save();
110
+ } catch (err: any) {
111
+ console.error('PDF Generation failed:', err);
112
+ alert('PDF generation failed: ' + (err.message || 'Unknown error'));
113
+ } finally {
114
+ // 8. Cleanup
115
+ if (document.body.contains(iframe)) {
116
+ document.body.removeChild(iframe);
117
+ }
118
+ }
119
+ }