@omniradiology/omnirad 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +438 -0
- package/app/api/ai-config/route.ts +131 -0
- package/app/api/ai-config/test/route.ts +49 -0
- package/app/api/auth/auto-login/route.ts +66 -0
- package/app/api/auth/check/route.ts +17 -0
- package/app/api/auth/login/route.ts +72 -0
- package/app/api/auth/logout/route.ts +25 -0
- package/app/api/auth/me/route.ts +75 -0
- package/app/api/auth/password/route.ts +49 -0
- package/app/api/auth/setup/route.ts +63 -0
- package/app/api/auth/users/route.ts +100 -0
- package/app/api/auth/wipe/route.ts +27 -0
- package/app/api/compliance/anonymize/patient/[id]/route.ts +104 -0
- package/app/api/compliance/audit/route.ts +110 -0
- package/app/api/compliance/export/patient/[id]/route.ts +108 -0
- package/app/api/compliance/restrict/patient/[id]/route.ts +59 -0
- package/app/api/compliance/settings/route.ts +93 -0
- package/app/api/copilot/annotate/route.ts +94 -0
- package/app/api/copilot/chat/route.ts +238 -0
- package/app/api/copilot/history/route.ts +95 -0
- package/app/api/copilot/reports/route.ts +81 -0
- package/app/api/fhir/Bundle/report/[id]/route.ts +85 -0
- package/app/api/fhir/DiagnosticReport/[id]/route.ts +45 -0
- package/app/api/fhir/ImagingStudy/[id]/route.ts +57 -0
- package/app/api/fhir/Patient/[id]/route.ts +26 -0
- package/app/api/fhir/ServiceRequest/route.ts +85 -0
- package/app/api/fhir/config/route.ts +102 -0
- package/app/api/fhir/config/test-connection/route.ts +49 -0
- package/app/api/fhir/metadata/route.ts +51 -0
- package/app/api/pacs/metadata/route.ts +32 -0
- package/app/api/pacs/qido/instances/route.ts +39 -0
- package/app/api/pacs/qido/series/route.ts +38 -0
- package/app/api/pacs/qido/studies/route.ts +37 -0
- package/app/api/pacs/test/route.ts +30 -0
- package/app/api/pacs/wado/render/route.ts +51 -0
- package/app/api/patients/[id]/reports/route.ts +18 -0
- package/app/api/patients/[id]/route.ts +43 -0
- package/app/api/patients/merge/route.ts +57 -0
- package/app/api/patients/route.ts +67 -0
- package/app/api/patients/search/route.ts +25 -0
- package/app/api/reports/[id]/route.ts +84 -0
- package/app/api/reports/[id]/status/route.ts +87 -0
- package/app/api/reports/clear/route.ts +16 -0
- package/app/api/reports/route.ts +112 -0
- package/app/api/segmentation-config/route.ts +238 -0
- package/app/api/settings/route.ts +245 -0
- package/app/api/settings/test-supabase/route.ts +103 -0
- package/app/api/upload/route.ts +48 -0
- package/app/copilot/page.tsx +30 -0
- package/app/globals.css +141 -0
- package/app/history/page.tsx +242 -0
- package/app/icon.svg +3 -0
- package/app/layout.tsx +47 -0
- package/app/login/page.tsx +175 -0
- package/app/pacs/page.tsx +78 -0
- package/app/page.tsx +125 -0
- package/app/patients/[id]/page.tsx +315 -0
- package/app/patients/page.tsx +110 -0
- package/app/profile/page.tsx +208 -0
- package/app/reports/page.tsx +432 -0
- package/app/settings/page.tsx +454 -0
- package/app/setup/page.tsx +199 -0
- package/components/admin/AuditLogTable.tsx +293 -0
- package/components/copilot/ActivityIndicator.tsx +215 -0
- package/components/copilot/ChatHistoryPanel.tsx +140 -0
- package/components/copilot/ChatMessage.tsx +251 -0
- package/components/copilot/ClickableReference.tsx +40 -0
- package/components/copilot/CopilotCornerstoneViewer.tsx +562 -0
- package/components/copilot/CopilotPanel.tsx +311 -0
- package/components/copilot/FindingsList.tsx +75 -0
- package/components/copilot/ViewerPanel.tsx +460 -0
- package/components/copilot/WorkspaceLayout.tsx +398 -0
- package/components/dashboard/AIConfigPanel.tsx +339 -0
- package/components/dashboard/AppearancePanel.tsx +491 -0
- package/components/dashboard/ApprovalModal.tsx +163 -0
- package/components/dashboard/CollaborationPanel.tsx +134 -0
- package/components/dashboard/CopilotConfigPanel.tsx +337 -0
- package/components/dashboard/DicomViewer.tsx +645 -0
- package/components/dashboard/FhirIntegrationPanel.tsx +331 -0
- package/components/dashboard/FullReportOverlay.tsx +269 -0
- package/components/dashboard/ImageViewer.tsx +541 -0
- package/components/dashboard/PatientForm.tsx +597 -0
- package/components/dashboard/RejectionModal.tsx +74 -0
- package/components/dashboard/ReportEditor.tsx +160 -0
- package/components/dashboard/ReportTemplates.tsx +729 -0
- package/components/dashboard/ReportView.tsx +539 -0
- package/components/dashboard/SegmentationConfigPanel.tsx +490 -0
- package/components/dashboard/StudyPlaceholder.tsx +17 -0
- package/components/dashboard/SupabaseIntegrationPanel.tsx +345 -0
- package/components/dashboard/UserManagementPanel.tsx +272 -0
- package/components/layout/ClientLayout.tsx +39 -0
- package/components/layout/Header.tsx +20 -0
- package/components/layout/Sidebar.tsx +119 -0
- package/components/pacs/PacsImageViewerModal.tsx +121 -0
- package/components/pacs/PacsSearchFilters.tsx +117 -0
- package/components/pacs/PacsSeriesViewer.tsx +190 -0
- package/components/pacs/PacsStudyTable.tsx +113 -0
- package/components/patients/patient-card.tsx +117 -0
- package/components/patients/patient-header.tsx +122 -0
- package/components/patients/patient-search.tsx +137 -0
- package/components/patients/patient-timeline.tsx +153 -0
- package/components/settings/ComplianceSettingsPanel.tsx +278 -0
- package/components/settings/SecurityPanel.tsx +418 -0
- package/components/ui/badge.tsx +19 -0
- package/components/ui/basic.tsx +156 -0
- package/db/index.ts +350 -0
- package/db/migrations/0000_odd_quasimodo.sql +117 -0
- package/db/migrations/meta/0000_snapshot.json +778 -0
- package/db/migrations/meta/_journal.json +13 -0
- package/db/schema.ts +239 -0
- package/drizzle.config.ts +10 -0
- package/lib/api.ts +689 -0
- package/lib/auth.ts +22 -0
- package/lib/copilot/action-executor.ts +94 -0
- package/lib/copilot/action-types.ts +72 -0
- package/lib/copilot/coordinate-mapper.ts +84 -0
- package/lib/dicomImageExtractor.ts +103 -0
- package/lib/dicomMetadataParser.ts +111 -0
- package/lib/fhir/client.ts +25 -0
- package/lib/fhir/constants.ts +21 -0
- package/lib/fhir/diagnostic-report.ts +88 -0
- package/lib/fhir/helpers.ts +73 -0
- package/lib/fhir/imaging-study.ts +49 -0
- package/lib/fhir/patient.ts +55 -0
- package/lib/fhir/service-request.ts +85 -0
- package/lib/fhir.ts +6 -0
- package/lib/pacs/dicom-utils.ts +72 -0
- package/lib/pacs/dicomweb.ts +72 -0
- package/lib/pacs/server-utils.ts +37 -0
- package/lib/patients.ts +25 -0
- package/lib/pdfHelper.ts +119 -0
- package/lib/reportHtmlGenerator.ts +581 -0
- package/lib/security/audit.ts +180 -0
- package/lib/security/authz.ts +246 -0
- package/lib/security/phi-redaction.ts +156 -0
- package/lib/security/rate-limit.ts +106 -0
- package/lib/security/secrets.ts +179 -0
- package/lib/supabase.ts +72 -0
- package/lib/utils.ts +6 -0
- package/next.config.ts +35 -0
- package/package.json +76 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/logo.svg +8 -0
- package/public/next.svg +1 -0
- package/public/omnirad-favicon.svg +8 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/tsconfig.json +34 -0
- package/types/copilot-viewer.ts +155 -0
- package/types/copilot.ts +105 -0
- package/types/fhir.ts +21 -0
- package/types/html2pdf.d.ts +20 -0
- package/types/index.ts +139 -0
- package/types/pacs.ts +41 -0
|
@@ -0,0 +1,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,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
|
+
}
|
package/lib/patients.ts
ADDED
|
@@ -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
|
+
}
|
package/lib/pdfHelper.ts
ADDED
|
@@ -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
|
+
}
|