Skip to main content
Glama
send-oru-message.ts22.5 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import { formatHl7DateTime, getReferenceString, getIdentifier, Hl7Context, Hl7Field, Hl7Message, Hl7Segment, } from '@medplum/core'; import type { BotEvent, MedplumClient } from '@medplum/core'; import type { DiagnosticReport, Observation, Patient, Practitioner, Reference, ServiceRequest, Specimen, Attachment, HumanName, } from '@medplum/fhirtypes'; // Constants const FACILITY_CODE = 'MEDPLUM_LAB'; const FACILITY_URL = 'https://lab.medplum.com'; const FACILITY_ORDER_ID = new URL('orderId', FACILITY_URL).toString(); const FACILITY_PATIENT_ID = new URL('patientId', FACILITY_URL).toString(); /** * This Bot demonstrates how to create and send lab results in the form of HL7v2 ORU messages * * See: https://v2plus.hl7.org/2021Jan/message-structure/ORU_R01.html * * @param medplum - The Medplum Client object * @param event - The BotEvent object * @returns The result of the operation */ export async function handler( medplum: MedplumClient, event: BotEvent<DiagnosticReport> ): Promise<Hl7Message | undefined> { try { // The event input is a DiagnosticReport const diagnosticReport = event.input; // Get the related resources const { serviceRequest, observations, specimen, patient, interpreter, presentedFormAttachments } = await fetchRelatedResources(diagnosticReport, medplum); if (!serviceRequest || !patient) { throw new Error('Could not find required resources for ORU message'); } // Get the order ID from the ServiceRequest const orderId = getIdentifier(serviceRequest, FACILITY_ORDER_ID); if (!orderId) { throw new Error(`No order ID found for ServiceRequest: ${getReferenceString(serviceRequest)}`); } // Create the ORU message const oruMessage = createOruMessage( diagnosticReport, serviceRequest, observations, specimen, patient, interpreter, presentedFormAttachments ); return oruMessage; } catch (error: any) { console.error(error.message); throw error; } } /** * Fetches all the resources needed to create an ORU message * * @param diagnosticReport - The DiagnosticReport resource * @param medplum - The Medplum client * @returns The related resources */ async function fetchRelatedResources( diagnosticReport: DiagnosticReport, medplum: MedplumClient ): Promise<{ serviceRequest?: ServiceRequest; observations: Observation[]; specimen?: Specimen; patient?: Patient; interpreter?: Practitioner; presentedFormAttachments?: Attachment[]; }> { // Get the ServiceRequest from the basedOn reference let serviceRequest: ServiceRequest | undefined; if (diagnosticReport.basedOn && diagnosticReport.basedOn.length > 0) { try { serviceRequest = await medplum.readReference(diagnosticReport.basedOn[0] as Reference<ServiceRequest>); } catch (err) { console.warn('Could not find ServiceRequest', err); } } // Get all Observations referenced in the report const observations: Observation[] = []; if (diagnosticReport.result && diagnosticReport.result.length > 0) { await Promise.all( diagnosticReport.result.map(async (obsRef) => { try { const obs = await medplum.readReference(obsRef as Reference<Observation>); observations.push(obs); // If this observation has child observations, fetch them as well if (obs.hasMember && obs.hasMember.length > 0) { await Promise.all( obs.hasMember.map(async (memberRef) => { try { const childObs = await medplum.readReference(memberRef as Reference<Observation>); observations.push(childObs); } catch (err) { console.warn(`Could not find child Observation: ${getReferenceString(memberRef)}`, err); } }) ); } } catch (err) { console.warn(`Could not find Observation: ${getReferenceString(obsRef)}`, err); } }) ); } // Get the Specimen if available let specimen: Specimen | undefined; if (serviceRequest?.specimen && serviceRequest.specimen.length > 0) { try { specimen = await medplum.readReference(serviceRequest.specimen[0] as Reference<Specimen>); } catch (err) { console.warn('Could not find Specimen', err); } } // Get the Patient let patient: Patient | undefined; if (diagnosticReport.subject) { try { patient = await medplum.readReference(diagnosticReport.subject as Reference<Patient>); } catch (err) { console.warn('Could not find Patient', err); } } // Get the Interpreter (Practitioner) let interpreter: Practitioner | undefined; if (diagnosticReport?.resultsInterpreter?.[0]) { try { interpreter = await medplum.readReference(diagnosticReport.resultsInterpreter[0] as Reference<Practitioner>); } catch (err) { console.warn('Could not find Interpreter', err); } } // Get the PDF attachments content as base64 encoded strings let presentedFormAttachments: Attachment[] = []; if (diagnosticReport.presentedForm) { presentedFormAttachments = await Promise.all( diagnosticReport.presentedForm .filter((form) => form.contentType === 'application/pdf') .map(async (form) => { if (form.data) { // If data is directly provided, clean up the data URL prefix return { ...form, data: form.data.replace(/^data:.*?;base64,/, ''), }; } // If URL is provided, fetch the contents and return the base64 encoded string if (!form.url) { throw new Error('PDF attachment must have either data or url'); } const binaryData = await medplum.download(form.url); const arrayBuffer = await binaryData.arrayBuffer(); const base64Content = Buffer.from(arrayBuffer).toString('base64'); return { ...form, data: base64Content, }; }) ); } return { serviceRequest, observations, specimen, patient, interpreter, presentedFormAttachments }; } /** * Creates an HL7 ORU message from FHIR resources * * @param diagnosticReport - The DiagnosticReport resource * @param serviceRequest - The ServiceRequest resource * @param observations - The Observation resources * @param specimen - The Specimen resource (optional) * @param patient - The Patient resource * @param interpreter - The ordering Practitioner (optional) * @param presentedFormAttachments - The PDF attachments * @returns The HL7 ORU message */ export function createOruMessage( diagnosticReport: DiagnosticReport, serviceRequest: ServiceRequest, observations: Observation[], specimen?: Specimen, patient?: Patient, interpreter?: Practitioner, presentedFormAttachments?: Attachment[] ): Hl7Message | undefined { if (!patient) { console.error('Patient resource is required for ORU message'); return undefined; } const orderId = getIdentifier(serviceRequest, FACILITY_ORDER_ID); if (!orderId) { console.error('Order ID not found in ServiceRequest'); return undefined; } const segments: Hl7Segment[] = []; // Use newline as the segment separator const context = new Hl7Context('\n'); const now = new Date(); // Message Header (MSH) segments.push( new Hl7Segment([ 'MSH', // MSH '^~\\&', // Field separator and encoding characters 'MEDPLUM_LAB', // Sending application FACILITY_CODE, // Sending facility '', // Receiving application 'RECEIVING_FACILITY', // Receiving facility formatHl7DateTime(now), // Date/time of message '', // Security 'ORU^R01', // Message type^Event type generateMessageId(), // Message control ID 'P', // Processing ID (P = Production) '2.5', // HL7 version ...Array(7).fill(''), // Additional optional fields ]) ); // Patient Identification (PID) segments.push(createPidSegment(patient)); // Patient Visit (PV1) if (serviceRequest.encounter) { segments.push(createPv1Segment(interpreter)); } // Order Observation (OBR) segments.push(createObrSegment(diagnosticReport, serviceRequest, specimen)); // Add observations (OBX segments) observations.forEach((observation, index) => { const obxSegments = createObxSegments(observation, index + 1); segments.push(...obxSegments); // Add notes as NTE segments if present if (observation.note && observation.note.length > 0) { observation.note.forEach((note, noteIndex) => { if (note.text) { segments.push(createNteSegment(note.text, noteIndex + 1)); } }); } }); // Add diagnostic report presented for as OBX segment, with base64 encoded contents if (presentedFormAttachments) { const obxIndex = observations.length; presentedFormAttachments.map((form, index) => { const segment = createObxPdfSegment(form, obxIndex + index + 1); segments.push(segment); return segment; }); } return new Hl7Message(segments, context); } /** * Creates a PID (Patient Identification) segment * * @param patient - The Patient resource * @returns An HL7 PID segment */ function createPidSegment(patient: Patient): Hl7Segment { const patientIdentifier = getIdentifier(patient, FACILITY_PATIENT_ID) || ''; return new Hl7Segment([ 'PID', // PID '1', // Set ID patientIdentifier, // Patient ID (External ID) '', // Patient ID (Internal ID) '', // Alternate Patient ID formatHl7Name(patient.name?.[0]), // Patient Name '', // Mother's Maiden Name patient.birthDate || '', // Date of Birth (YYYYMMDD) mapFhirGender(patient.gender), // Sex '', // Patient Alias '', // Race formatHl7Address(patient.address?.[0]), // Patient Address '', // County Code formatHl7Telecom(patient.telecom, 'phone', 'home'), // Home Phone formatHl7Telecom(patient.telecom, 'phone', 'work'), // Business Phone '', // Primary Language '', // Marital Status '', // Religion '', // Patient Account Number '', // SSN Number - Patient '', // Driver's License - Patient ]); } /** * Creates a PV1 (Patient Visit) segment * * @param interpreter - The ordering Practitioner * @returns An HL7 PV1 segment */ function createPv1Segment(interpreter?: Practitioner): Hl7Segment { return new Hl7Segment([ 'PV1', // PV1 '1', // Set ID 'O', // Patient Class (O = Outpatient) '', // Assigned Patient Location '', // Admission Type '', // Preadmit Number '', // Prior Patient Location interpreter ? formatHl7Provider(interpreter) : '', // Attending Doctor '', // Referring Doctor '', // Consulting Doctor '', // Hospital Service '', // Temporary Location '', // Preadmit Test Indicator '', // Readmission Indicator '', // Admit Source '', // Discharge Disposition '', // Discharge To Location '', // Diet Type '', // Servicing Facility ]); } /** * Creates an OBR (Observation Request) segment * * @param diagnosticReport - The DiagnosticReport resource * @param serviceRequest - The ServiceRequest resource * @param specimen - The Specimen resource (optional) * @returns An HL7 OBR segment */ function createObrSegment( diagnosticReport: DiagnosticReport, serviceRequest: ServiceRequest, specimen?: Specimen ): Hl7Segment { const orderId = getIdentifier(serviceRequest, FACILITY_ORDER_ID) || ''; let collectionDateTime = ''; if (specimen?.collection?.collectedDateTime) { collectionDateTime = formatHl7DateTime(new Date(specimen.collection.collectedDateTime)); } const reportStatus = mapFhirStatusToHl7(diagnosticReport.status); return new Hl7Segment([ 'OBR', // OBR '1', // Set ID orderId, // Placer Order Number '', // Filler Order Number formatHl7CodeableConcept(diagnosticReport.code), // Universal Service ID '', // Priority '', // Requested Date/Time collectionDateTime, // Observation Date/Time '', // Observation End Date/Time '', // Collection Volume '', // Collector Identifier '', // Specimen Action Code '', // Danger Code '', // Relevant Clinical Info '', // Specimen Received Date/Time '', // Specimen Source '', // Ordering Provider '', // Order Callback Phone Number '', // Placer Field 1 '', // Placer Field 2 '', // Filler Field 1 '', // Filler Field 2 formatHl7DateTime(new Date(diagnosticReport.issued || '')), // Results Rpt/Status Chng - Date/Time '', // Charge to Practice '', // Diagnostic Serv Sect ID reportStatus, // Result Status ]); } /** * Creates OBX (Observation Result) segments for an Observation * * @param observation - The Observation resource * @param setId - The set ID for this observation * @returns An array of HL7 OBX segments */ function createObxSegments(observation: Observation, setId: number): Hl7Segment[] { const segments: Hl7Segment[] = []; const valueType = determineObxValueType(observation); const status = mapFhirStatusToHl7(observation.status); // Create the main OBX segment segments.push( new Hl7Segment([ 'OBX', // OBX setId.toString(), // Set ID valueType, // Value Type formatHl7CodeableConcept(observation.code), // Observation Identifier '', // Observation Sub-ID formatObservationValue(observation, valueType), // Observation Value formatHl7Unit(observation.valueQuantity?.unit), // Units formatReferenceRange(observation.referenceRange), // Reference Range formatHl7Interpretation(observation.interpretation), // Abnormal Flags '', // Probability '', // Nature of Abnormal Test status, // Observation Result Status '', // Date Last Observation Normal Value '', // User Defined Access Checks formatHl7DateTime(new Date(observation.issued || '')), // Date/Time of the Observation '', // Producer's ID '', // Responsible Observer '', // Observation Method ]) ); // If there's a PDF or other media attachment, add additional OBX segments if (observation.hasMember) { // This would handle any OBX segments for attachments // Implementation would depend on your specific requirements } return segments; } /** * Creates an NTE (Notes and Comments) segment * * @param text - The note text * @param setId - The set ID for this note * @returns An HL7 NTE segment */ function createNteSegment(text: string, setId: number): Hl7Segment { return new Hl7Segment([ 'NTE', // NTE setId.toString(), // Set ID '', // Source of Comment text, // Comment ]); } /* Helper Functions */ /** * Generates a unique message ID for the HL7 message * * @returns A unique message ID */ function generateMessageId(): string { return 'MEDPLUM_' + Date.now().toString(); } /** * Formats a FHIR HumanName into an HL7 field * * @param name - The FHIR HumanName * @returns The formatted HL7 name field */ function formatHl7Name(name: HumanName | undefined): string { if (!name) { return ''; } const family = name.family || ''; const given = name.given?.[0] || ''; const middle = name.given?.[1] || ''; const prefix = name.prefix?.[0] || ''; const suffix = name.suffix?.[0] || ''; // Format: last^first^middle^suffix^prefix return new Hl7Field([[family, given, middle, suffix, prefix]]).toString(); } /** * Formats a FHIR Address into an HL7 field * * @param address - The FHIR Address * @returns The formatted HL7 address field */ function formatHl7Address(address: any): string { if (!address) { return ''; } const street = address.line?.[0] || ''; const otherStreet = address.line?.[1] || ''; const city = address.city || ''; const state = address.state || ''; const zip = address.postalCode || ''; const country = address.country || ''; // Format: street^otherStreet^city^state^zip^country return new Hl7Field([[street, otherStreet, city, state, zip, country]]).toString(); } /** * Formats FHIR ContactPoint array into a specific type/use * * @param telecom - The FHIR ContactPoint array * @param system - The system to filter by (e.g., 'phone', 'email') * @param use - The use to filter by (e.g., 'home', 'work') * @returns The formatted telephone number */ function formatHl7Telecom(telecom: any[] | undefined, system: string, use: string): string { if (!telecom?.length) { return ''; } const contact = telecom.find((t) => t.system === system && t.use === use); return contact?.value || ''; } /** * Maps FHIR gender to HL7 gender code * * @param gender - The FHIR gender * @returns The HL7 gender code */ function mapFhirGender(gender: string | undefined): string { switch (gender?.toLowerCase()) { case 'male': return 'M'; case 'female': return 'F'; case 'other': return 'O'; default: return 'U'; } } /** * Formats a FHIR CodeableConcept into an HL7 field * * @param concept - The FHIR CodeableConcept * @returns The formatted HL7 field */ function formatHl7CodeableConcept(concept: any): string { if (!concept?.coding?.length) { return ''; } const coding = concept.coding[0]; const code = coding.code || ''; const display = coding.display || concept.text || ''; const system = coding.system || ''; // Format: code^text^coding system return new Hl7Field([[code, display, system]]).toString(); } /** * Formats a FHIR Practitioner into an HL7 provider field * * @param practitioner - The FHIR Practitioner * @returns The formatted HL7 provider field */ function formatHl7Provider(practitioner: Practitioner): string { if (!practitioner?.identifier?.length) { return ''; } const id = practitioner.identifier[0].value || ''; const name = practitioner.name?.[0]; if (!name) { return id; } const family = name.family || ''; const given = name.given?.[0] || ''; const middle = name.given?.[1] || ''; // Format: ID^LastName^FirstName^MiddleInitial return new Hl7Field([[id], [family], [given], [middle]]).toString(); } /** * Formats a unit for an HL7 field * * @param unit - The unit string * @returns The formatted unit */ function formatHl7Unit(unit: string | undefined): string { return unit || ''; } /** * Formats FHIR ReferenceRange into an HL7 field * * @param ranges - The FHIR ReferenceRange array * @returns The formatted reference range */ function formatReferenceRange(ranges: any[] | undefined): string { if (!ranges?.length || !ranges[0]) { return ''; } const range = ranges[0]; if (range.low && range.high) { return `${range.low.value}-${range.high.value}`; } else if (range.low) { return `>${range.low.value}`; } else if (range.high) { return `<${range.high.value}`; } return ''; } /** * Formats FHIR Interpretation into an HL7 abnormal flags field * * @param interpretations - The FHIR Interpretation array * @returns The formatted abnormal flags */ function formatHl7Interpretation(interpretations: any[] | undefined): string { if (!interpretations?.length || !interpretations[0]?.coding?.[0]?.code) { return ''; } return interpretations[0].coding[0].code; } /** * Determines the OBX value type based on the Observation * * @param observation - The Observation resource * @returns The HL7 OBX value type */ function determineObxValueType(observation: Observation): string { if (observation.valueQuantity) { return 'NM'; // Numeric } else if (observation.valueString) { return 'ST'; // String } else if (observation.valueCodeableConcept) { return 'CE'; // Coded Entry } else if (observation.valueDateTime) { return 'DT'; // Date/Time } else if (observation.dataAbsentReason) { return 'ST'; // String for null values with reason } return 'ST'; // Default to String } /** * Formats an Observation value based on its type * * @param observation - The Observation resource * @param valueType - The OBX value type * @returns The formatted observation value */ function formatObservationValue(observation: Observation, valueType: string): string { if (observation.dataAbsentReason) { return ''; // Empty value with abnormal flags } switch (valueType) { case 'NM': if (observation.valueQuantity?.comparator) { return `${observation.valueQuantity.comparator}${observation.valueQuantity.value}`; } return observation.valueQuantity?.value?.toString() || ''; case 'ST': return observation.valueString || ''; case 'CE': if (observation.valueCodeableConcept?.coding?.[0]) { const coding = observation.valueCodeableConcept.coding[0]; return `${coding.code}^${coding.display}^${coding.system}`; } return ''; case 'DT': if (observation.valueDateTime) { return formatHl7DateTime(new Date(observation.valueDateTime)); } return ''; default: return ''; } } /** * Maps FHIR status to HL7 status code * * @param status - The FHIR status * @returns The HL7 status code */ function mapFhirStatusToHl7(status: string | undefined): string { switch (status) { case 'final': return 'F'; case 'preliminary': return 'P'; case 'corrected': return 'C'; case 'cancelled': return 'X'; case 'registered': return 'I'; default: return 'P'; // Default to preliminary } } /** * Creates an OBX segment for a PDF attachment * * @param form - The Attachment containing the PDF data or URL * @param setId - The set ID for this observation * @returns An HL7 OBX segment for the PDF */ function createObxPdfSegment(form: Attachment, setId: number): Hl7Segment { let base64Content = ''; if (form.data) { base64Content = form.data; } else { throw new Error('Parsed presentedForm attachments must have data'); } return new Hl7Segment([ 'OBX', // OBX setId.toString(), // Set ID 'ED', // Value Type (ED = Encapsulated Data) 'PDF^PDFBASE64', // Observation Identifier (PDF^PDFBASE64) '1', // Observation Sub-ID `${base64Content}`, // Observation Value (<content>) ]); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/medplum/medplum'

If you have feedback or need assistance with the MCP directory API, please join our Discord server