Skip to main content
Glama
convert.ts19.2 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import type { Address, Bundle, CareTeam, ClinicalImpression, CodeableConcept, Composition, CompositionSection, ContactPoint, Device, ExtractResource, HumanName, Organization, Patient, Practitioner, PractitionerRole, Reference, RelatedPerson, Resource, ResourceType, ServiceRequest, } from '@medplum/fhirtypes'; import { mapFhirToCcdaDateTime } from '../datetime'; import { OID_ASSESSMENTS_SECTION, OID_AUTHOR_PARTICIPANT, OID_HL7_REGISTERED_MODELS, OID_LOINC_CODE_SYSTEM, OID_PATIENT_REFERRAL_ACTIVITY_OBSERVATION, OID_REASON_FOR_REFERRAL, OID_RELATED_PERSON_RELATIONSHIP_AND_NAME_PARTICIPANT_PARTICIPATION, } from '../oids'; import { CONFIDENTIALITY_MAPPER, LOINC_ASSESSMENTS_SECTION, LOINC_REASON_FOR_REFERRAL_SECTION, LOINC_REFERRAL_NOTE, LOINC_SUMMARY_OF_EPISODE_NOTE, mapCodeableConceptToCcdaCode, } from '../systems'; import { CCDA_TEMPLATE_IDS, LOINC_TO_TEMPLATE_IDS, REFERRAL_TEMPLATE_IDS } from '../templates'; import type { Ccda, CcdaAuthor, CcdaCode, CcdaCustodian, CcdaEntry, CcdaInformationRecipient, CcdaParticipant, CcdaRecordTarget, CcdaSection, CcdaTemplateId, } from '../types'; import { createAllergyEntry } from './entries/allergy'; import { createPlanOfTreatmentCarePlanEntry } from './entries/careplan'; import { createCareTeamEntry } from './entries/careteam'; import { createClinicalImpressionEntry } from './entries/clinicalimpression'; import { createConditionEntry } from './entries/condition'; import { createDeviceUseStatementEntry } from './entries/deviceusestatement'; import { createDiagnosticReportEntry } from './entries/diagnosticreport'; import { createEncounterEntry } from './entries/encounter'; import { createGoalEntry } from './entries/goal'; import { createImmunizationEntry } from './entries/immunization'; import { createInsuranceEntry } from './entries/insurance'; import { createMedicationEntry } from './entries/medication'; import { createObservationEntry } from './entries/observation'; import { createHistoryOfProceduresEntry } from './entries/procedure'; import { createPlanOfTreatmentServiceRequestEntry } from './entries/servicerequest'; import { mapDocumentationOf } from './metadata'; import { mapEffectiveTime, mapFhirAddressArrayToCcdaAddressArray, mapFhirTextDivToCcdaSectionText, mapIdentifiers, mapNames, mapPatient, mapTelecom, } from './utils'; export interface FhirToCcdaOptions { /** * Type of C-CDA document to generate. */ type?: 'referral' | 'discharge' | 'summary'; } /** * Convert a FHIR bundle to a C-CDA document. * @param bundle - The FHIR bundle to convert. * @param options - Optional options. * @returns The C-CDA document. */ export function convertFhirToCcda(bundle: Bundle, options?: FhirToCcdaOptions): Ccda { return new FhirToCcdaConverter(bundle, options).convert(); } /** * The FhirToCcdaConverter class is responsible for converting a FHIR bundle to a C-CDA document. */ export class FhirToCcdaConverter { private readonly bundle: Bundle; private readonly options: FhirToCcdaOptions | undefined; private readonly composition: Composition; private readonly patient: Patient; /** * Creates a new FhirToCcdaConverter for the given FHIR bundle. * @param bundle - The FHIR bundle to convert. * @param options - Optional options. */ constructor(bundle: Bundle, options?: FhirToCcdaOptions) { this.bundle = bundle; this.options = options; const composition = this.findResource('Composition'); if (!composition) { throw new Error('Composition not found'); } const patient = this.findResource('Patient'); if (!patient) { throw new Error('Patient not found'); } this.composition = composition; this.patient = patient; } /** * Convert the FHIR bundle to a C-CDA document. * @returns The C-CDA document. */ convert(): Ccda { const sections = this.createSections(); const referral = this.composition.section ?.find((s) => s.code?.coding?.[0]?.code === LOINC_REASON_FOR_REFERRAL_SECTION) ?.entry?.find((e) => e.reference?.startsWith('ServiceRequest/')) as Reference<ServiceRequest> | undefined; let templateId: CcdaTemplateId[]; let code: CcdaCode | undefined; if (this.options?.type === 'referral') { templateId = REFERRAL_TEMPLATE_IDS; code = { '@_code': LOINC_REFERRAL_NOTE, '@_displayName': 'Referral Note', '@_codeSystem': OID_LOINC_CODE_SYSTEM, '@_codeSystemName': 'LOINC', }; } else { templateId = CCDA_TEMPLATE_IDS; code = { '@_code': LOINC_SUMMARY_OF_EPISODE_NOTE, '@_displayName': 'Summarization of Episode Note', '@_codeSystem': OID_LOINC_CODE_SYSTEM, '@_codeSystemName': 'LOINC', }; } // Be careful! Order is important! // Validate changes with ETT: https://ett.healthit.gov/ett/#/validators/ccdauscidv3#ccdaValdReport return { realmCode: { '@_code': 'US', }, typeId: { '@_root': OID_HL7_REGISTERED_MODELS, '@_extension': 'POCD_HD000040', }, templateId, id: mapIdentifiers(this.composition.id, undefined), code, title: this.composition.title, effectiveTime: mapEffectiveTime(this.composition.date, undefined), confidentialityCode: this.composition.confidentiality ? CONFIDENTIALITY_MAPPER.mapFhirToCcdaCode(this.composition.confidentiality) : undefined, // Consol US Realm Header SHALL contain exactly one [1..1] languageCode, // which SHALL be selected from ValueSet Language 2.16.840.1.113883.1.11.11526 DYNAMIC (CONF:5372, R2.1=CONF:1198-5372, DSTU:806) languageCode: { '@_code': this.composition.language ?? 'en-US' }, recordTarget: this.createRecordTarget(), author: this.mapAuthor(this.composition.author?.[0], this.composition.date, true), custodian: this.mapCustodian(this.composition.custodian), informationRecipient: this.mapRecipient(referral), participant: this.createParticipants(), documentationOf: mapDocumentationOf(this.composition.event), component: sections.length > 0 ? { structuredBody: { component: sections.map((section) => ({ section: [section], })), }, } : undefined, }; } /** * Find a resource in the FHIR bundle by resource type. * @param resourceType - The type of resource to find. * @returns The resource if found, otherwise undefined. */ private findResource<K extends ResourceType>(resourceType: K): ExtractResource<K> | undefined { return this.bundle.entry?.find((e) => e.resource?.resourceType === resourceType)?.resource as ExtractResource<K>; } /** * Find a resource in the FHIR bundle by reference. * @param reference - The reference to the resource. * @returns The resource if found, otherwise undefined. */ findResourceByReference<T extends Resource>(reference: Reference<T> | undefined): T | undefined { if (!reference?.reference) { return undefined; } const [resourceType, id] = reference.reference.split('/'); if (!resourceType || !id) { return undefined; } return this.bundle.entry?.find((e) => e.resource?.resourceType === resourceType && e.resource?.id === id) ?.resource as T; } /** * Find resources in the FHIR bundle by references. * @param references - The references to the resources. * @returns The resources if found, otherwise undefined. */ private findResourcesByReferences(references: Reference[] | undefined): Resource[] { if (!references) { return []; } return references.map((ref) => this.findResourceByReference(ref)).filter((r): r is Resource => !!r); } /** * Create the record target for the C-CDA document. * @returns The record target. */ private createRecordTarget(): CcdaRecordTarget[] { if (!this.patient) { throw new Error('Patient not found'); } return [ { patientRole: { id: mapIdentifiers(this.patient.id, this.patient.identifier), addr: mapFhirAddressArrayToCcdaAddressArray(this.patient.address), telecom: mapTelecom(this.patient.telecom), patient: mapPatient(this.patient), }, }, ]; } /** * Create the sections for the C-CDA document. * @returns The sections. */ private createSections(): CcdaSection[] { const sections: CcdaSection[] = []; if (this.composition.section) { for (const section of this.composition.section) { sections.push(this.createSection(section)); } } return sections; } private createSection(section: CompositionSection): CcdaSection { // Get the section code to determine type const sectionCode = section.code?.coding?.[0]?.code; if (!sectionCode) { throw new Error(`Missing section code: ${JSON.stringify(section.code)}`); } const templateId = LOINC_TO_TEMPLATE_IDS[sectionCode]; if (!templateId) { throw new Error(`Unknown section code: ${sectionCode}`); } const resources = this.findResourcesByReferences(section.entry); // Assessments section is special case, because it does not have any "entry" elements // Instead, the entire clinical impression resource is included in the section if ( sectionCode === LOINC_ASSESSMENTS_SECTION && resources.length === 1 && resources[0].resourceType === 'ClinicalImpression' ) { return this.createClinicalImpressionSection(section, resources[0] as ClinicalImpression); } if ( sectionCode === LOINC_REASON_FOR_REFERRAL_SECTION && resources.length === 1 && resources[0].resourceType === 'ServiceRequest' ) { return this.createReasonForReferralSection(section, resources[0] as ServiceRequest); } return { templateId: templateId, code: mapCodeableConceptToCcdaCode(section.code), title: section.title, text: mapFhirTextDivToCcdaSectionText(section.text), entry: resources.map((resource) => this.createEntry(section, resource)).filter(Boolean) as CcdaEntry[], '@_nullFlavor': resources.length === 0 ? 'NI' : undefined, }; } private createEntry(section: CompositionSection, resource: Resource): CcdaEntry | undefined { switch (resource.resourceType) { case 'Account': return createInsuranceEntry(this, resource); case 'AllergyIntolerance': return createAllergyEntry(this, resource); case 'CarePlan': return createPlanOfTreatmentCarePlanEntry(this, resource); case 'CareTeam': return createCareTeamEntry(this, resource); case 'ClinicalImpression': return createClinicalImpressionEntry(this, resource); case 'Condition': return createConditionEntry(this, section, resource); case 'DeviceUseStatement': return createDeviceUseStatementEntry(this, resource); case 'DiagnosticReport': return createDiagnosticReportEntry(this, resource); case 'Encounter': return createEncounterEntry(this, resource); case 'Goal': return createGoalEntry(section, resource); case 'Immunization': return createImmunizationEntry(this, resource); case 'MedicationRequest': return createMedicationEntry(this, resource); case 'Procedure': return createHistoryOfProceduresEntry(this, resource); case 'Observation': return createObservationEntry(this, resource); case 'ServiceRequest': return createPlanOfTreatmentServiceRequestEntry(this, resource); default: return undefined; } } /** * Map the FHIR author to the C-CDA author. * @param author - The author to map. * @param time - The time to map. * @param includeDevice - Whether to include device information. * @returns The C-CDA author. */ mapAuthor( author: | Reference<CareTeam | Device | Organization | Patient | Practitioner | PractitionerRole | RelatedPerson> | undefined, time?: string, includeDevice?: boolean ): CcdaAuthor[] | undefined { if (!author) { return undefined; } let mainResource = this.findResourceByReference(author); if (!mainResource) { return undefined; } let organization: Organization | undefined = undefined; if (mainResource.resourceType === 'Organization') { organization = mainResource; } else if (mainResource.resourceType === 'PractitionerRole') { organization = this.findResourceByReference(mainResource.organization); mainResource = this.findResourceByReference(mainResource.practitioner); } if (!mainResource) { return undefined; } let address: Address[] | undefined = undefined; if ('address' in mainResource) { address = mainResource.address; } let telecom: ContactPoint[] | undefined = undefined; if ('telecom' in mainResource) { telecom = mainResource.telecom; } let code: CodeableConcept | undefined = undefined; if ('qualification' in mainResource) { code = mainResource.qualification?.[0]; } let humanName: HumanName[] | undefined = undefined; if (['Patient', 'Practitioner', 'RelatedPerson'].includes(mainResource.resourceType)) { humanName = (mainResource as Patient | Practitioner | RelatedPerson).name; } return [ { templateId: [ { '@_root': OID_AUTHOR_PARTICIPANT, }, ], time: time ? { '@_value': mapFhirToCcdaDateTime(time) } : undefined, assignedAuthor: { id: mapIdentifiers(mainResource.id, mainResource.identifier), addr: mapFhirAddressArrayToCcdaAddressArray(address), telecom: mapTelecom(telecom), code: mapCodeableConceptToCcdaCode(code), assignedPerson: humanName ? { name: mapNames(humanName) } : undefined, assignedAuthoringDevice: !humanName && includeDevice ? { manufacturerModelName: 'Medplum', softwareName: 'Medplum' } : undefined, representedOrganization: organization?.name ? { name: [organization.name] } : undefined, }, }, ]; } private mapCustodian(custodian: Reference<Organization> | undefined): CcdaCustodian | undefined { if (!custodian) { return undefined; } const organization = this.findResourceByReference(custodian); if (!organization) { return undefined; } return { assignedCustodian: { representedCustodianOrganization: { id: mapIdentifiers(organization.id, organization.identifier), name: organization.name ? [organization.name] : undefined, telecom: mapTelecom(organization.telecom), addr: mapFhirAddressArrayToCcdaAddressArray(organization.address), }, }, }; } private mapRecipient(referral: Reference<ServiceRequest> | undefined): CcdaInformationRecipient | undefined { if (!referral) { return undefined; } const serviceRequest = this.findResourceByReference(referral); if (!serviceRequest) { return undefined; } const recipient = serviceRequest.performer; if (!recipient || recipient.length === 0) { return undefined; } const resource = this.findResourceByReference(recipient[0]); if (resource?.resourceType !== 'Practitioner') { return undefined; } return { intendedRecipient: { informationRecipient: { name: mapNames(resource.name), }, }, }; } private createParticipants(): CcdaParticipant[] | undefined { const relatedPersons = this.bundle.entry ?.filter((e) => e.resource?.resourceType === 'RelatedPerson') .map((e) => e.resource as RelatedPerson); if (!relatedPersons || relatedPersons.length === 0) { return undefined; } return relatedPersons.map((person) => ({ '@_typeCode': 'IND', templateId: [ { '@_root': OID_RELATED_PERSON_RELATIONSHIP_AND_NAME_PARTICIPANT_PARTICIPATION, '@_extension': '2023-05-01' }, ], associatedEntity: { '@_classCode': 'PRS', id: mapIdentifiers(person.id, person.identifier), code: mapCodeableConceptToCcdaCode(person.relationship?.[0]), addr: mapFhirAddressArrayToCcdaAddressArray(person.address), telecom: mapTelecom(person.telecom), associatedPerson: { name: mapNames(person.name), }, }, })); } /** * Handles the ClinicalImpression special case. * Unlike most other sections, the "Assessments" section can skip the `<entry>` elements and directly contain the `<text>` element. * @param section - The Composition section to create the C-CDA section for. * @param resource - The ClinicalImpression resource to create the C-CDA section for. * @returns The C-CDA section for the ClinicalImpression resource. */ private createClinicalImpressionSection(section: CompositionSection, resource: ClinicalImpression): CcdaSection { return { templateId: [{ '@_root': OID_ASSESSMENTS_SECTION }], code: mapCodeableConceptToCcdaCode(section.code), title: section.title, text: resource.summary, author: this.mapAuthor(resource.assessor, resource.date), }; } /** * Handles the Reason for Referral special case. * @param section - The Composition section to create the C-CDA section for. * @param resource - The ClinicalImpression resource to create the C-CDA section for. * @returns The C-CDA section for the ClinicalImpression resource. */ private createReasonForReferralSection(section: CompositionSection, resource: ServiceRequest): CcdaSection { return { templateId: [ { '@_root': OID_REASON_FOR_REFERRAL, '@_extension': '2014-06-09' }, { '@_root': OID_REASON_FOR_REFERRAL }, ], code: mapCodeableConceptToCcdaCode(section.code), title: section.title, text: resource.note?.[0]?.text, entry: [ { act: [ { '@_classCode': 'PCPR', '@_moodCode': 'INT', templateId: [{ '@_root': OID_PATIENT_REFERRAL_ACTIVITY_OBSERVATION }], id: mapIdentifiers(resource.id, resource.identifier), code: mapCodeableConceptToCcdaCode(resource.code) as CcdaCode, statusCode: { '@_code': 'active' }, effectiveTime: [{ '@_value': mapFhirToCcdaDateTime(resource.occurrenceDateTime) }], priorityCode: { '@_code': 'A', '@_codeSystem': '2.16.840.1.113883.5.7', '@_codeSystemName': 'ActPriority', '@_displayName': 'ASAP', }, author: this.mapAuthor(resource.requester, resource.occurrenceDateTime), }, ], }, ], }; } }

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