Skip to main content
Glama
ccda-to-fhir.ts48 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import { createReference, generateId, isUUID, LOINC, UCUM } from '@medplum/core'; import type { Address, AllergyIntolerance, AllergyIntoleranceReaction, Bundle, CarePlan, CareTeam, CareTeamParticipant, CodeableConcept, Coding, Composition, CompositionEvent, CompositionSection, Condition, ContactPoint, Encounter, EncounterDiagnosis, Extension, Goal, GoalTarget, HumanName, Identifier, Immunization, ImmunizationPerformer, Medication, Observation, ObservationReferenceRange, Organization, Patient, Period, Practitioner, PractitionerQualification, PractitionerRole, Procedure, Reference, RelatedPerson, Resource, } from '@medplum/fhirtypes'; import { mapCcdaToFhirDate, mapCcdaToFhirDateTime } from './datetime'; import { OID_ALLERGIES_SECTION_ENTRIES_OPTIONAL, OID_ALLERGIES_SECTION_ENTRIES_OPTIONAL_V2, OID_ALLERGIES_SECTION_ENTRIES_REQUIRED, OID_ALLERGIES_SECTION_ENTRIES_REQUIRED_V2, OID_CARE_TEAMS_SECTION, OID_GOAL_OBSERVATION, OID_GOALS_SECTION, OID_HEALTH_CONCERNS_SECTION, OID_IMMUNIZATIONS_SECTION_ENTRIES_OPTIONAL, OID_IMMUNIZATIONS_SECTION_ENTRIES_REQUIRED, OID_MEDICATION_FREE_TEXT_SIG, OID_MEDICATIONS_SECTION_ENTRIES_REQUIRED, OID_NOTES_SECTION, OID_PAYERS_SECTION, OID_PLAN_OF_CARE_SECTION, OID_PROBLEMS_SECTION_ENTRIES_OPTIONAL, OID_PROBLEMS_SECTION_ENTRIES_REQUIRED, OID_PROBLEMS_SECTION_V2_ENTRIES_OPTIONAL, OID_PROBLEMS_SECTION_V2_ENTRIES_REQUIRED, OID_PROCEDURES_SECTION_ENTRIES_REQUIRED, OID_REASON_FOR_REFERRAL, } from './oids'; import { ACT_CODE_SYSTEM, ADDRESS_USE_MAPPER, ALLERGY_CLINICAL_CODE_SYSTEM, ALLERGY_SEVERITY_MAPPER, ALLERGY_STATUS_MAPPER, ALLERGY_VERIFICATION_CODE_SYSTEM, CCDA_NARRATIVE_REFERENCE_URL, CLINICAL_CONDITION_CODE_SYSTEM, CONDITION_CATEGORY_CODE_SYSTEM, CONDITION_VER_STATUS_CODE_SYSTEM, CONDITION_VERIFICATION_CODE_SYSTEM, DIAGNOSIS_ROLE_CODE_SYSTEM, ENCOUNTER_STATUS_MAPPER, HUMAN_NAME_USE_MAPPER, IMMUNIZATION_STATUS_MAPPER, LOINC_SUMMARY_OF_EPISODE_NOTE, mapCcdaCodeToCodeableConcept, mapCcdaSystemToFhir, MEDICATION_STATUS_MAPPER, OBSERVATION_CATEGORY_MAPPER, PARTICIPATION_CODE_SYSTEM, PROBLEM_STATUS_MAPPER, PROCEDURE_STATUS_MAPPER, TELECOM_USE_MAPPER, US_CORE_CONDITION_URL, US_CORE_ETHNICITY_URL, US_CORE_MEDICATION_REQUEST_URL, US_CORE_RACE_URL, } from './systems'; import type { Ccda, CcdaAct, CcdaAddr, CcdaAssignedEntity, CcdaAuthor, CcdaCode, CcdaCustodian, CcdaDocumentationOf, CcdaEffectiveTime, CcdaEncounter, CcdaEntry, CcdaId, CcdaName, CcdaObservation, CcdaOrganizer, CcdaOrganizerComponent, CcdaParticipant, CcdaPatientRole, CcdaPerformer, CcdaProcedure, CcdaReferenceRange, CcdaSection, CcdaSubstanceAdministration, CcdaTelecom, CcdaTemplateId, CcdaText, } from './types'; import { convertToCompactXml } from './xml'; export interface CcdaToFhirOptions { ignoreUnsupportedSections?: boolean; } /** * Converts C-CDA documents to FHIR resources * Following Medplum TypeScript rules: * - Generates new FHIR resource IDs * - Preserves original C-CDA IDs as identifiers * - Adds proper metadata and timestamps * * @param ccda - The C-CDA document to convert * @param options - Optional conversion options * @returns The converted FHIR resources */ export function convertCcdaToFhir(ccda: Ccda, options?: CcdaToFhirOptions): Bundle { return new CcdaToFhirConverter(ccda, options).convert(); } class CcdaToFhirConverter { private readonly ccda: Ccda; private readonly options: CcdaToFhirOptions | undefined; private readonly resources: Resource[] = []; private patient?: Patient; constructor(ccda: Ccda, options?: CcdaToFhirOptions) { this.ccda = ccda; this.options = options; } convert(): Bundle { this.processHeader(); const composition = this.createComposition(); return { resourceType: 'Bundle', type: 'document', entry: [ { resource: composition }, ...(this.patient ? [{ resource: this.patient }] : []), ...this.resources.map((resource) => ({ resource })), ], }; } private processHeader(): void { const patientRole = this.ccda.recordTarget?.[0]?.patientRole; if (patientRole) { this.patient = this.createPatient(patientRole); this.createRelatedPersons(); } } private createPatient(patientRole: CcdaPatientRole): Patient { const patient = patientRole.patient; const extensions: Extension[] = []; if (patient.raceCode && patient.raceCode.length > 0 && !patient.raceCode[0]['@_nullFlavor']) { extensions.push({ url: US_CORE_RACE_URL, extension: patient.raceCode.map((raceCode) => ({ url: 'ombCategory', valueCoding: this.mapCodeToCoding(raceCode), })), }); } if (patient.ethnicGroupCode && patient.ethnicGroupCode.length > 0 && !patient.ethnicGroupCode[0]['@_nullFlavor']) { extensions.push({ url: US_CORE_ETHNICITY_URL, extension: patient.ethnicGroupCode.map((ethnicGroupCode) => ({ url: 'ombCategory', valueCoding: this.mapCodeToCoding(ethnicGroupCode), })), }); } return { resourceType: 'Patient', id: this.mapId(patientRole.id), identifier: this.mapIdentifiers(patientRole.id), name: this.mapCcdaNameArrayFhirHumanNameArray(patient.name), gender: this.mapGenderCode(patient.administrativeGenderCode?.['@_code']), birthDate: mapCcdaToFhirDate(patient.birthTime?.['@_value']), address: this.mapAddresses(patientRole.addr), telecom: this.mapTelecom(patientRole.telecom), extension: extensions.length > 0 ? extensions : undefined, }; } private createRelatedPersons(): void { const participants = this.ccda.participant; if (!participants) { return; } for (const participant of participants) { const relatedPerson = this.mapParticipantToRelatedPerson(participant); if (relatedPerson) { this.resources.push(relatedPerson); } } } private mapParticipantToRelatedPerson(participant: CcdaParticipant): RelatedPerson | undefined { const patient = this.patient; if (!patient) { return undefined; } const associatedEntity = participant.associatedEntity; if (!associatedEntity) { return undefined; } const relationship = mapCcdaCodeToCodeableConcept(associatedEntity.code); return { resourceType: 'RelatedPerson', id: this.mapId(associatedEntity.id), patient: createReference(patient), relationship: relationship ? [relationship] : undefined, identifier: this.mapIdentifiers(associatedEntity.id), name: this.mapCcdaNameArrayFhirHumanNameArray(associatedEntity.associatedPerson?.name), address: this.mapAddresses(associatedEntity.addr), telecom: this.mapTelecom(associatedEntity.telecom), }; } private mapId(ids: CcdaId[] | undefined): string { // If there is an id without a root, then use that as the FHIR resource ID const serverId = ids?.find((id) => !id['@_extension'] && id['@_root'] && isUUID(id['@_root'])); if (serverId) { return serverId['@_root'] as string; } // Otherwise generate a UUID return generateId(); } private mapIdentifiers(ids: CcdaId[] | undefined): Identifier[] | undefined { if (!ids) { return undefined; } const result: Identifier[] = []; for (const id of ids) { if (!id['@_extension'] && id['@_root'] && isUUID(id['@_root'])) { // By convention, we use id without a root as the FHIR resource ID continue; } result.push({ system: mapCcdaSystemToFhir(id['@_root']), value: id['@_extension'], }); } return result; } private mapCcdaNameArrayFhirHumanNameArray(names: CcdaName[] | undefined): HumanName[] | undefined { return names?.map((n) => this.mapCcdaNameToFhirHumanName(n)).filter(Boolean) as HumanName[]; } private mapCcdaNameToFhirHumanName(name: CcdaName | undefined): HumanName | undefined { if (!name) { return undefined; } const result: HumanName = {}; const use = name['@_use'] ? HUMAN_NAME_USE_MAPPER.mapCcdaToFhir(name['@_use']) : undefined; if (use) { result.use = use; } if (name.prefix) { result.prefix = name.prefix.map(nodeToString)?.filter(Boolean) as string[]; } if (name.family) { result.family = nodeToString(name.family); } if (name.given) { result.given = name.given.map(nodeToString)?.filter(Boolean) as string[]; } if (name.suffix) { result.suffix = name.suffix.map(nodeToString)?.filter(Boolean) as string[]; } return result; } private mapAddresses(addresses: CcdaAddr[] | undefined): Address[] | undefined { if (!addresses || addresses.length === 0 || addresses.every((addr) => addr['@_nullFlavor'] === 'UNK')) { return undefined; } return addresses?.map((addr) => ({ use: addr['@_use'] ? ADDRESS_USE_MAPPER.mapCcdaToFhir(addr['@_use']) : undefined, line: addr.streetAddressLine, city: addr.city, state: addr.state, postalCode: addr.postalCode, country: addr.country, })); } private mapTelecom(telecoms: CcdaTelecom[] | undefined): ContactPoint[] | undefined { if (!telecoms || telecoms.length === 0 || telecoms.every((tel) => tel['@_nullFlavor'] === 'UNK')) { return undefined; } return telecoms?.map((tel) => ({ use: tel['@_use'] ? TELECOM_USE_MAPPER.mapCcdaToFhir(tel['@_use']) : undefined, system: this.getTelecomSystem(tel['@_value']), value: this.getTelecomValue(tel['@_value']), })); } private createComposition(): Composition { const components = this.ccda.component?.structuredBody?.component || []; const sections: CompositionSection[] = []; for (const component of components) { for (const section of component.section) { const resources = this.processSection(section); sections.push({ title: section.title, code: this.mapCode(section.code), text: { status: 'generated', div: `<div xmlns="http://www.w3.org/1999/xhtml">${convertToCompactXml(section.text)}</div>`, }, entry: resources.map(createReference), }); this.resources.push(...resources); } } return { resourceType: 'Composition', id: this.mapId(this.ccda.id), language: this.ccda.languageCode?.['@_code'], status: 'final', type: this.ccda.code ? (this.mapCode(this.ccda.code) as CodeableConcept) : { coding: [{ system: LOINC, code: LOINC_SUMMARY_OF_EPISODE_NOTE }] }, confidentiality: this.ccda.confidentialityCode?.['@_code'] as Composition['confidentiality'], author: this.ccda.author?.[0] ? [ this.mapAuthorToReference(this.ccda.author?.[0]) as Reference< Practitioner | Organization | Patient | PractitionerRole >, ] : [{ display: 'Medplum' }], custodian: this.mapCustodianToReference(this.ccda.custodian), event: this.mapDocumentationOfToEvent(this.ccda.documentationOf), date: mapCcdaToFhirDateTime(this.ccda.effectiveTime?.[0]?.['@_value']) ?? new Date().toISOString(), title: this.ccda.title ?? 'Medical Summary', section: sections, }; } private processSection(section: CcdaSection): Resource[] { const resources: Resource[] = []; if (section.entry) { for (const entry of section.entry) { this.processEntry(section, entry, resources); } } return resources; } private processEntry(section: CcdaSection, entry: CcdaEntry, resources: Resource[]): void { for (const act of entry.act ?? []) { const resource = this.processAct(section, act); if (resource) { resources.push(resource); } } for (const substanceAdmin of entry.substanceAdministration ?? []) { const resource = this.processSubstanceAdministration(section, substanceAdmin); if (resource) { resources.push(resource); } } for (const organizer of entry.organizer ?? []) { resources.push(this.processOrganizer(section, organizer)); } for (const observation of entry.observation ?? []) { resources.push(this.processObservation(section, observation)); } for (const encounter of entry.encounter ?? []) { resources.push(this.processEncounter(section, encounter)); } for (const procedure of entry.procedure ?? []) { resources.push(this.processProcedure(section, procedure)); } } private processAct(section: CcdaSection, act: CcdaAct): Resource | undefined { const templateId = section.templateId[0]['@_root']; switch (templateId) { case OID_ALLERGIES_SECTION_ENTRIES_REQUIRED: case OID_ALLERGIES_SECTION_ENTRIES_OPTIONAL: case OID_ALLERGIES_SECTION_ENTRIES_REQUIRED_V2: case OID_ALLERGIES_SECTION_ENTRIES_OPTIONAL_V2: return this.processAllergyIntoleranceAct(act); case OID_PROBLEMS_SECTION_ENTRIES_REQUIRED: case OID_PROBLEMS_SECTION_ENTRIES_OPTIONAL: case OID_PROBLEMS_SECTION_V2_ENTRIES_REQUIRED: case OID_PROBLEMS_SECTION_V2_ENTRIES_OPTIONAL: return this.processConditionAct(act); case OID_PLAN_OF_CARE_SECTION: return this.processCarePlanAct(act); case OID_HEALTH_CONCERNS_SECTION: return this.processConditionAct(act); case OID_PROCEDURES_SECTION_ENTRIES_REQUIRED: return this.processProcedureAct(act); case OID_REASON_FOR_REFERRAL: // This is part of USCDI v3, which is optional, and not yet implemented return undefined; case OID_NOTES_SECTION: // This is part of USCDI v3, which is optional, and not yet implemented return undefined; case OID_PAYERS_SECTION: // This is part of USCDI v3, which is optional, and not yet implemented return undefined; default: if (this.options?.ignoreUnsupportedSections) { return undefined; } throw new Error('Unhandled act templateId: ' + templateId); } } private processAllergyIntoleranceAct(act: CcdaAct): Resource | undefined { const observation = act.entryRelationship?.find((rel) => rel['@_typeCode'] === 'SUBJ')?.observation?.[0]; if (!observation) { return undefined; } const allergy: AllergyIntolerance = { resourceType: 'AllergyIntolerance', id: this.mapId(act.id), clinicalStatus: this.createClinicalStatus(act), verificationStatus: this.createVerificationStatus(), type: 'allergy', category: ['food'], patient: createReference(this.patient as Patient), recorder: this.mapAuthorToReference(act.author?.[0]), recordedDate: this.mapEffectiveTimeToDateTime(act.effectiveTime?.[0]), onsetDateTime: this.mapEffectiveTimeToDateTime(observation.effectiveTime?.[0]), }; // Set category based on the observation.value code if ((observation.value as CcdaCode)?.['@_code'] === '414285001') { allergy.category = ['food']; } allergy.extension = this.mapTextReference(observation.text); const allergenCode = observation.participant?.[0]?.participantRole?.playingEntity?.code; if (allergenCode) { allergy.code = this.mapCode(allergenCode); // Add allergen reference if (allergy.code && allergenCode.originalText?.reference?.['@_value']) { allergy.code.extension = this.mapTextReference(allergenCode.originalText); } } const reactionObservations = observation.entryRelationship?.find( (rel) => rel['@_typeCode'] === 'MFST' )?.observation; if (reactionObservations) { allergy.reaction = reactionObservations.map((ro) => this.processReaction(ro)); } allergy.asserter = this.mapAuthorToReference(observation.author?.[0]); return allergy; } private processConditionAct(act: CcdaAct): Resource | undefined { const observation = act.entryRelationship?.find((rel) => rel['@_typeCode'] === 'SUBJ')?.observation?.[0]; if (!observation) { return undefined; } const result: Condition = { resourceType: 'Condition', id: this.mapId(act.id), identifier: this.concatArrays(this.mapIdentifiers(act.id), this.mapIdentifiers(observation.id)), meta: { profile: [US_CORE_CONDITION_URL], }, clinicalStatus: { coding: [ { system: CLINICAL_CONDITION_CODE_SYSTEM, code: PROBLEM_STATUS_MAPPER.mapCcdaToFhirWithDefault(act.statusCode?.['@_code'], 'active'), }, ], }, verificationStatus: { coding: [ { system: CONDITION_VERIFICATION_CODE_SYSTEM, code: 'confirmed', }, ], }, category: [ { coding: [ { system: CONDITION_CATEGORY_CODE_SYSTEM, code: 'problem-list-item', display: 'Problem List Item', }, ], }, ], code: this.mapCode(observation.value as CcdaCode), subject: createReference(this.patient as Patient), onsetDateTime: mapCcdaToFhirDateTime(observation.effectiveTime?.[0]?.low?.['@_value']), abatementDateTime: mapCcdaToFhirDateTime(observation.effectiveTime?.[0]?.high?.['@_value']), recordedDate: this.mapEffectiveTimeToDateTime(act.effectiveTime?.[0]), recorder: this.mapAuthorToReference(observation.author?.[0]), asserter: this.mapAuthorToReference(observation.author?.[0]), }; result.extension = this.mapTextReference(observation.text); return result; } private processCarePlanAct(act: CcdaAct): Resource | undefined { const result: CarePlan = { resourceType: 'CarePlan', id: this.mapId(act.id), identifier: this.mapIdentifiers(act.id), status: 'completed', intent: 'plan', title: 'CARE PLAN', category: act.code ? [this.mapCode(act.code) as CodeableConcept] : undefined, subject: createReference(this.patient as Patient), description: nodeToString(act.text), }; return result; } private processProcedureAct(act: CcdaAct): Resource | undefined { const result: Procedure = { resourceType: 'Procedure', id: this.mapId(act.id), identifier: this.mapIdentifiers(act.id), status: 'completed', code: this.mapCode(act.code), subject: createReference(this.patient as Patient), performedDateTime: mapCcdaToFhirDateTime(act.effectiveTime?.[0]?.['@_value']), recorder: this.mapAuthorToReference(act.author?.[0]), asserter: this.mapAuthorToReference(act.author?.[0]), extension: this.mapTextReference(act.text), }; return result; } private processSubstanceAdministration( section: CcdaSection, substanceAdmin: CcdaSubstanceAdministration ): Resource | undefined { const templateId = section.templateId[0]['@_root']; switch (templateId) { case OID_MEDICATIONS_SECTION_ENTRIES_REQUIRED: case OID_PLAN_OF_CARE_SECTION: return this.processMedicationSubstanceAdministration(substanceAdmin); case OID_IMMUNIZATIONS_SECTION_ENTRIES_OPTIONAL: case OID_IMMUNIZATIONS_SECTION_ENTRIES_REQUIRED: return this.processImmunizationSubstanceAdministration(substanceAdmin); default: if (this.options?.ignoreUnsupportedSections) { return undefined; } throw new Error('Unhandled substance administration templateId: ' + templateId); } } private processMedicationSubstanceAdministration(substanceAdmin: CcdaSubstanceAdministration): Resource | undefined { const cdaId = this.mapId(substanceAdmin.id); const medicationCode = substanceAdmin.consumable?.manufacturedProduct?.[0]?.manufacturedMaterial?.[0]?.code?.[0]; const routeCode = substanceAdmin.routeCode; const doseQuantity = substanceAdmin.doseQuantity; const manufacturerOrg = substanceAdmin.consumable?.manufacturedProduct?.[0]?.manufacturerOrganization?.[0]; const instructions = substanceAdmin.entryRelationship?.find( (rel) => rel.substanceAdministration?.[0]?.templateId?.[0]?.['@_root'] === OID_MEDICATION_FREE_TEXT_SIG )?.substanceAdministration?.[0]; let medication: Medication | undefined = undefined; let medicationCodeableConcept: CodeableConcept | undefined = undefined; if (manufacturerOrg) { // If there is a manufacturer, create a Medication resource // We need to do this to fully represent the medication for round trip data preservation medication = { resourceType: 'Medication', id: 'med-' + cdaId, code: this.mapCode(medicationCode), extension: this.mapTextReference(medicationCode?.originalText), manufacturer: manufacturerOrg ? { identifier: { value: manufacturerOrg.id?.[0]?.['@_root'], }, display: manufacturerOrg.name?.[0], } : undefined, }; } else { // Otherwise, create a CodeableConcept for the medication // Avoid contained resources as much as possible medicationCodeableConcept = { ...this.mapCode(medicationCode), extension: this.mapTextReference(medicationCode?.originalText), }; } return { resourceType: 'MedicationRequest', id: cdaId, contained: medication ? [medication] : undefined, meta: { profile: [US_CORE_MEDICATION_REQUEST_URL], }, extension: this.mapTextReference(substanceAdmin.text), status: MEDICATION_STATUS_MAPPER.mapCcdaToFhirWithDefault(substanceAdmin.statusCode?.['@_code'], 'active'), intent: 'order', medicationReference: medication ? { reference: '#med-' + cdaId } : undefined, medicationCodeableConcept, subject: createReference(this.patient as Patient), authoredOn: mapCcdaToFhirDateTime(substanceAdmin.author?.[0]?.time?.['@_value']), dispenseRequest: substanceAdmin.effectiveTime?.[0] ? { validityPeriod: { start: mapCcdaToFhirDateTime(substanceAdmin.effectiveTime?.[0]?.low?.['@_value']), end: mapCcdaToFhirDateTime(substanceAdmin.effectiveTime?.[0]?.high?.['@_value']), }, } : undefined, dosageInstruction: [ { text: typeof substanceAdmin.text === 'string' ? substanceAdmin.text : undefined, extension: this.mapTextReference(instructions?.text), route: routeCode ? this.mapCode(routeCode) : undefined, timing: { repeat: substanceAdmin.effectiveTime?.[1]?.period ? { period: Number(substanceAdmin.effectiveTime?.[1]?.period['@_value']), periodUnit: substanceAdmin.effectiveTime?.[1]?.period['@_unit'], } : undefined, }, doseAndRate: doseQuantity ? [ { doseQuantity: { system: UCUM, value: Number(doseQuantity['@_value']), code: '[IU]', unit: '[IU]', }, }, ] : undefined, }, ], }; } private processImmunizationSubstanceAdministration( substanceAdmin: CcdaSubstanceAdministration ): Resource | undefined { const consumable = substanceAdmin.consumable; if (!consumable) { return undefined; } const result: Immunization = { resourceType: 'Immunization', id: this.mapId(substanceAdmin.id), identifier: this.mapIdentifiers(substanceAdmin.id), status: IMMUNIZATION_STATUS_MAPPER.mapCcdaToFhirWithDefault(substanceAdmin.statusCode?.['@_code'], 'completed'), vaccineCode: this.mapCode( consumable.manufacturedProduct?.[0]?.manufacturedMaterial?.[0]?.code?.[0] ) as CodeableConcept, patient: createReference(this.patient as Patient), occurrenceDateTime: mapCcdaToFhirDateTime(substanceAdmin.effectiveTime?.[0]?.['@_value']), lotNumber: consumable.manufacturedProduct?.[0]?.manufacturedMaterial?.[0]?.lotNumberText?.[0], }; if (substanceAdmin.performer) { result.performer = this.mapCcdaPerformerArrayToImmunizationPerformerArray(substanceAdmin.performer); } result.extension = this.mapTextReference(substanceAdmin.text); if (substanceAdmin.consumable?.manufacturedProduct?.[0]?.manufacturerOrganization?.[0]) { result.manufacturer = { display: substanceAdmin.consumable?.manufacturedProduct?.[0]?.manufacturerOrganization?.[0]?.name?.[0], }; } return result; } private processReaction(reactionObs: CcdaObservation): AllergyIntoleranceReaction { const reaction: AllergyIntoleranceReaction = { id: this.mapId(reactionObs.id), manifestation: [this.mapCode(reactionObs.value as CcdaCode)] as CodeableConcept[], onset: mapCcdaToFhirDateTime(reactionObs.effectiveTime?.[0]?.low?.['@_value']), }; this.processSeverity(reactionObs, reaction); // Add reaction reference if (reaction.manifestation && reaction.manifestation.length > 0 && reactionObs.text?.reference?.['@_value']) { reaction.manifestation[0].extension = this.mapTextReference(reactionObs.text); } return reaction; } private processSeverity(reactionObs: CcdaObservation, reaction: AllergyIntoleranceReaction): void { const severityObs = reactionObs.entryRelationship?.find((rel) => rel['@_typeCode'] === 'SUBJ')?.observation?.[0]; if (!severityObs) { return; } const severityCode = (severityObs.value as CcdaCode)?.['@_code']; if (severityCode) { reaction.severity = ALLERGY_SEVERITY_MAPPER.mapCcdaToFhir(severityCode); } reaction.extension = this.mapTextReference(severityObs.text); } private mapEffectiveTimeToDateTime(effectiveTime: CcdaEffectiveTime | undefined): string | undefined { if (effectiveTime?.['@_value']) { return mapCcdaToFhirDateTime(effectiveTime['@_value']); } if (effectiveTime?.low?.['@_value']) { return mapCcdaToFhirDateTime(effectiveTime.low['@_value']); } return undefined; } private mapEffectiveTimeToPeriod(effectiveTime: CcdaEffectiveTime | undefined): Period | undefined { if (!effectiveTime?.['@_value'] && (effectiveTime?.low || effectiveTime?.high)) { return { start: mapCcdaToFhirDateTime(effectiveTime?.low?.['@_value']), end: mapCcdaToFhirDateTime(effectiveTime?.high?.['@_value']), }; } return undefined; } private mapGenderCode(code: string | undefined): Patient['gender'] { if (!code) { return undefined; } const map: { [key: string]: Patient['gender'] } = { F: 'female', M: 'male', UN: 'unknown', }; return map[code]; } private getTelecomSystem(value: string | undefined): ContactPoint['system'] { if (!value) { return undefined; } if (value.startsWith('tel:')) { return 'phone'; } if (value.startsWith('mailto:')) { return 'email'; } return 'other'; } private getTelecomValue(value: string | undefined): string | undefined { if (!value) { return undefined; } return value.replace(/^(tel:|mailto:)/, ''); } private mapCode(code: CcdaCode | undefined): CodeableConcept | undefined { if (!code) { return undefined; } const result = { coding: [ { system: mapCcdaSystemToFhir(code['@_codeSystem']), code: code['@_code'], display: code['@_displayName'], }, ], text: code['@_displayName'], }; if (code.translation) { for (const translation of code.translation) { result.coding.push({ system: mapCcdaSystemToFhir(translation['@_codeSystem']), code: translation['@_code'], display: translation['@_displayName'], }); } } return result; } private mapCodeToCoding(code: CcdaCode | undefined): Coding | undefined { if (!code) { return undefined; } return { system: mapCcdaSystemToFhir(code['@_codeSystem']), code: code['@_code'], display: code['@_displayName'], }; } private createClinicalStatus(act: CcdaAct): CodeableConcept { return { coding: [ { system: ALLERGY_CLINICAL_CODE_SYSTEM, code: ALLERGY_STATUS_MAPPER.mapCcdaToFhirWithDefault(act.statusCode?.['@_code'], 'active'), }, ], }; } private createVerificationStatus(): CodeableConcept { return { coding: [ { system: ALLERGY_VERIFICATION_CODE_SYSTEM, code: 'confirmed', }, ], }; } private mapAuthorToReference(author: CcdaAuthor | undefined): Reference<Practitioner> | undefined { if (!author) { return undefined; } const practitioner: Practitioner = { resourceType: 'Practitioner', id: this.mapId(author.assignedAuthor?.id), identifier: this.mapIdentifiers(author.assignedAuthor?.id), name: this.mapCcdaNameArrayFhirHumanNameArray(author.assignedAuthor?.assignedPerson?.name), address: this.mapAddresses(author.assignedAuthor?.addr), telecom: this.mapTelecom(author.assignedAuthor?.telecom), qualification: author.assignedAuthor?.code ? [this.mapCode(author.assignedAuthor?.code) as PractitionerQualification] : undefined, }; this.resources.push(practitioner); return createReference(practitioner); } private mapAssignedEntityToReference( assignedEntity: CcdaAssignedEntity | undefined ): Reference<PractitionerRole> | undefined { if (!assignedEntity) { return undefined; } const assignedPerson = assignedEntity.assignedPerson; const representedOrganization = assignedEntity.representedOrganization; const practitioner: Practitioner = { resourceType: 'Practitioner', id: this.mapId(assignedEntity?.id), identifier: this.mapIdentifiers(assignedEntity?.id), name: this.mapCcdaNameArrayFhirHumanNameArray(assignedPerson?.name), address: this.mapAddresses(assignedEntity?.addr), telecom: this.mapTelecom(assignedEntity?.telecom), }; this.resources.push(practitioner); const organization: Organization = { resourceType: 'Organization', id: this.mapId(assignedEntity?.id), identifier: this.mapIdentifiers(representedOrganization?.id), name: representedOrganization?.name?.[0], address: this.mapAddresses(representedOrganization?.addr), }; this.resources.push(organization); const practitionerRole: PractitionerRole = { resourceType: 'PractitionerRole', id: this.mapId(assignedEntity?.id), practitioner: createReference(practitioner), organization: createReference(organization), }; this.resources.push(practitionerRole); return createReference(practitionerRole); } private mapCustodianToReference(custodian: CcdaCustodian | undefined): Reference<Organization> | undefined { if (!custodian) { return undefined; } const organization: Organization = { resourceType: 'Organization', id: this.mapId(custodian.assignedCustodian.representedCustodianOrganization.id), identifier: this.mapIdentifiers(custodian.assignedCustodian.representedCustodianOrganization.id), name: custodian.assignedCustodian.representedCustodianOrganization.name?.[0], address: this.mapAddresses(custodian.assignedCustodian.representedCustodianOrganization.addr), telecom: this.mapTelecom(custodian.assignedCustodian.representedCustodianOrganization.telecom), }; this.resources.push(organization); return createReference(organization); } private mapDocumentationOfToEvent(documentationOf: CcdaDocumentationOf | undefined): CompositionEvent[] | undefined { if (!documentationOf) { return undefined; } const serviceEvent = documentationOf.serviceEvent; if (!serviceEvent) { return undefined; } return [ { code: serviceEvent.code ? [this.mapCode(serviceEvent.code) as CodeableConcept] : undefined, period: this.mapEffectiveTimeToPeriod(serviceEvent.effectiveTime?.[0]), }, ]; } private concatArrays<T>(array1: T[] | undefined, array2: T[] | undefined): T[] | undefined { if (!array1) { return array2; } if (!array2) { return array1; } return [...array1, ...array2]; } private mapCcdaPerformerArrayToImmunizationPerformerArray(performers: CcdaPerformer[]): ImmunizationPerformer[] { const result: ImmunizationPerformer[] = []; for (const performer of performers) { const entity = performer.assignedEntity; const reference = this.mapAssignedEntityToReference(entity); if (reference) { result.push({ actor: reference }); } } return result; } private processOrganizer(section: CcdaSection, organizer: CcdaOrganizer): Resource { const templateId = section.templateId[0]['@_root']; if (templateId === OID_CARE_TEAMS_SECTION) { return this.processCareTeamOrganizer(organizer); } return this.processVitalsOrganizer(organizer); } private processCareTeamOrganizer(organizer: CcdaOrganizer): CareTeam { const participants: CareTeamParticipant[] = []; if (organizer.component) { for (const component of organizer.component) { const participant = this.processCareTeamMember(component); if (participant) { participants.push(participant); } } } const result: CareTeam = { resourceType: 'CareTeam', id: this.mapId(organizer.id), identifier: this.mapIdentifiers(organizer.id), participant: participants.length > 0 ? participants : undefined, }; return result; } private processCareTeamMember(component: CcdaOrganizerComponent): CareTeamParticipant | undefined { const act = component.act?.[0]; if (!act) { return undefined; } const performer = act.performer?.[0]; if (!performer) { return undefined; } return { role: performer.functionCode ? [this.mapCode(performer.functionCode) as CodeableConcept] : undefined, member: this.mapAssignedEntityToReference(performer.assignedEntity), period: this.mapEffectiveTimeToPeriod(act.effectiveTime?.[0]), }; } private processVitalsOrganizer(organizer: CcdaOrganizer): Observation { const result: Observation = { resourceType: 'Observation', id: this.mapId(organizer.id), identifier: this.mapIdentifiers(organizer.id), status: 'final', category: this.mapObservationTemplateIdToObservationCategory(organizer.templateId), code: this.mapCode(organizer.code) as CodeableConcept, subject: createReference(this.patient as Patient), }; if (organizer.effectiveTime?.[0]?.['@_value']) { result.effectiveDateTime = mapCcdaToFhirDateTime(organizer.effectiveTime?.[0]?.['@_value']); } if (organizer.component) { const members: Reference<Observation>[] = []; for (const component of organizer.component) { members.push(...this.processVitalsComponent(component)); } if (members.length > 0) { result.hasMember = members; } } return result; } private processVitalsComponent(component: CcdaOrganizerComponent): Reference<Observation>[] { const result: Reference<Observation>[] = []; if (component.observation) { for (const observation of component.observation) { const child = this.processVitalsObservation(observation); result.push(createReference(child)); this.resources.push(child); } } return result; } private processObservation(section: CcdaSection, observation: CcdaObservation): Resource { const observationTemplateId = observation.templateId[0]['@_root']; if (observationTemplateId === OID_GOALS_SECTION || observationTemplateId === OID_GOAL_OBSERVATION) { // Goal template return this.processGoalObservation(observation); } const sectionTemplateId = section.templateId[0]['@_root']; switch (sectionTemplateId) { case OID_PLAN_OF_CARE_SECTION: case OID_GOALS_SECTION: return this.processGoalObservation(observation); default: // Treat this as a normal observation by default return this.processVitalsObservation(observation); } } private processGoalObservation(observation: CcdaObservation): Goal { const category: CodeableConcept[] = []; if (observation.code) { category.push(this.mapCode(observation.code) as CodeableConcept); } const target: GoalTarget[] = []; let description: CodeableConcept | undefined = undefined; const ccdaValue = observation.value; if (ccdaValue) { const type = ccdaValue['@_xsi:type']; if (type === 'CD') { description = mapCcdaCodeToCodeableConcept(ccdaValue); } else if (type === 'ST') { description = { text: ccdaValue['#text'] ?? '' }; } } if (observation.entryRelationship) { for (const entryRelationship of observation.entryRelationship) { target.push({ measure: this.mapCode(entryRelationship.act?.[0]?.code) as CodeableConcept, detailCodeableConcept: this.mapCode(entryRelationship.act?.[0]?.code) as CodeableConcept, dueDate: mapCcdaToFhirDateTime(entryRelationship.act?.[0]?.effectiveTime?.[0]?.low?.['@_value']), }); } } const result: Goal = { resourceType: 'Goal', id: this.mapId(observation.id), extension: this.mapTextReference(observation.text), identifier: this.mapIdentifiers(observation.id), lifecycleStatus: this.mapGoalLifecycleStatus(observation), category, description: description ?? { text: 'Unknown goal' }, subject: createReference(this.patient as Patient), startDate: mapCcdaToFhirDate(observation.effectiveTime?.[0]?.['@_value']), target, }; return result; } private mapGoalLifecycleStatus(observation: CcdaObservation): Goal['lifecycleStatus'] { // - Map from observation's `statusCode/@code` // - Mapping logic: // - If statusCode is "active" → "active" // - If statusCode is "completed" → "achieved" // - If statusCode is "cancelled" → "cancelled" // - If statusCode is "aborted" → "cancelled" // - If no status or other value → "active" const map: { [key: string]: Goal['lifecycleStatus'] } = { active: 'active', completed: 'completed', cancelled: 'cancelled', aborted: 'cancelled', }; return map[observation.statusCode['@_code'] ?? 'active']; } private processVitalsObservation(observation: CcdaObservation): Observation { const result: Observation = { resourceType: 'Observation', id: this.mapId(observation.id), identifier: this.mapIdentifiers(observation.id), status: 'final', category: this.mapObservationTemplateIdToObservationCategory(observation.templateId), code: this.mapCode(observation.code) as CodeableConcept, subject: createReference(this.patient as Patient), referenceRange: this.mapReferenceRangeArray(observation.referenceRange), performer: observation.author ?.map((author) => this.mapAuthorToReference(author)) .filter(Boolean) as Reference<Practitioner>[], }; if (observation.value?.['@_xsi:type']) { switch (observation.value['@_xsi:type']) { case 'PQ': // Physical Quantity case 'CO': // Count of individuals result.valueQuantity = { value: observation.value['@_value'] ? Number.parseFloat(observation.value['@_value']) : undefined, unit: observation.value['@_unit'], system: UCUM, code: observation.value['@_unit'], }; break; case 'CD': // Code case 'CE': // Code with Extensions result.valueCodeableConcept = this.mapCode(observation.value); break; case 'ST': // String result.valueString = observation.value['#text'] ?? ''; break; case 'INT': // Integer result.valueInteger = observation.value['@_value'] ? Number.parseInt(observation.value['@_value'], 10) : undefined; break; default: console.warn(`Unhandled observation value type: ${observation.value['@_xsi:type']}`); } } if (observation.effectiveTime?.[0]?.['@_value']) { result.effectiveDateTime = mapCcdaToFhirDateTime(observation.effectiveTime?.[0]?.['@_value']); } result.extension = this.mapTextReference(observation.text); // Look for child observations const relationships = observation.entryRelationship; if (relationships) { for (const relationship of relationships) { const childObservation = relationship.observation; if (childObservation) { for (const child of childObservation) { const childResource = this.processVitalsObservation(child); this.resources.push(childResource); if (!result.hasMember) { result.hasMember = []; } result.hasMember.push(createReference(childResource)); } } } } return result; } private mapObservationTemplateIdToObservationCategory( templateIds: CcdaTemplateId[] | undefined ): CodeableConcept[] | undefined { if (!templateIds) { return undefined; } const codes = new Set<string>(); const result: CodeableConcept[] = []; for (const templateId of templateIds) { const category = OBSERVATION_CATEGORY_MAPPER.mapCcdaToFhirCodeableConcept(templateId['@_root']); if (category?.coding?.[0]?.code && !codes.has(category.coding[0].code)) { codes.add(category.coding[0].code); result.push(category); } } return Array.from(result.values()); } private mapReferenceRangeArray( referenceRange: CcdaReferenceRange[] | undefined ): ObservationReferenceRange[] | undefined { if (!referenceRange || referenceRange.length === 0) { return undefined; } return referenceRange.map((r) => this.mapReferenceRange(r)).filter(Boolean) as ObservationReferenceRange[]; } private mapReferenceRange(referenceRange: CcdaReferenceRange | undefined): ObservationReferenceRange | undefined { if (!referenceRange) { return undefined; } const observationRange = referenceRange.observationRange; if (!observationRange) { return undefined; } const result: ObservationReferenceRange = {}; result.extension = this.mapTextReference(observationRange.text); return result; } private processEncounter(section: CcdaSection, encounter: CcdaEncounter): Encounter { // Create the main encounter resource const result: Encounter = { resourceType: 'Encounter', id: this.mapId(encounter.id), identifier: this.mapIdentifiers(encounter.id), status: ENCOUNTER_STATUS_MAPPER.mapCcdaToFhirWithDefault(encounter.statusCode?.['@_code'], 'unknown'), class: { system: ACT_CODE_SYSTEM, code: encounter.code?.['@_code'] ?? 'AMB', display: encounter.code?.['@_displayName'] ?? 'Ambulatory', }, type: encounter.code ? [this.mapCode(encounter.code) as CodeableConcept] : undefined, subject: createReference(this.patient as Patient), period: this.mapEffectiveTimeToPeriod(encounter.effectiveTime?.[0]), }; // Add participant information if (encounter.performer) { result.participant = encounter.performer.map((performer) => ({ type: [ { coding: [ { system: PARTICIPATION_CODE_SYSTEM, code: performer['@_typeCode'] ?? 'PPRF', display: 'Primary Performer', }, ], }, ], individual: this.mapAssignedEntityToReference(performer.assignedEntity), })); } // Add diagnoses from entryRelationships if (encounter.entryRelationship) { const diagnoses = encounter.entryRelationship .filter((rel) => rel['@_typeCode'] === 'RSON') .map((rel) => { const observation = rel.observation?.[0]; if (!observation) { return undefined; } // Create Condition resource const condition: Condition = { resourceType: 'Condition', id: this.mapId(observation.id), identifier: this.mapIdentifiers(observation.id), clinicalStatus: { coding: [ { system: CLINICAL_CONDITION_CODE_SYSTEM, code: 'active', }, ], }, verificationStatus: { coding: [ { system: CONDITION_VER_STATUS_CODE_SYSTEM, code: 'confirmed', }, ], }, code: this.mapCode(observation.value as CcdaCode), subject: createReference(this.patient as Patient), onsetDateTime: mapCcdaToFhirDateTime(observation.effectiveTime?.[0]?.low?.['@_value']), }; // Add condition to resources array this.resources.push(condition); return { condition: createReference(condition), use: { coding: [ { system: DIAGNOSIS_ROLE_CODE_SYSTEM, code: 'AD', display: 'Admission diagnosis', }, ], }, }; }) .filter(Boolean) as EncounterDiagnosis[]; if (diagnoses.length > 0) { result.diagnosis = diagnoses; } } result.extension = this.mapTextReference(encounter.text); return result; } private processProcedure(section: CcdaSection, procedure: CcdaProcedure): Procedure { const result: Procedure = { resourceType: 'Procedure', id: this.mapId(procedure.id), identifier: this.mapIdentifiers(procedure.id), status: PROCEDURE_STATUS_MAPPER.mapCcdaToFhirWithDefault( procedure.statusCode?.['@_code'], 'completed' ) as Procedure['status'], code: this.mapCode(procedure.code), subject: createReference(this.patient as Patient), performedPeriod: this.mapEffectiveTimeToPeriod(procedure.effectiveTime?.[0]), bodySite: procedure.targetSiteCode ? [this.mapCode(procedure.targetSiteCode) as CodeableConcept] : undefined, extension: this.mapTextReference(procedure.text), }; return result; } private mapTextReference(text: string | CcdaText | undefined): Extension[] | undefined { if (!text || typeof text !== 'object') { return undefined; } if (!text?.reference?.['@_value']) { return undefined; } return [ { url: CCDA_NARRATIVE_REFERENCE_URL, valueString: text.reference?.['@_value'], }, ]; } } function nodeToString(node: CcdaText | string | undefined): string | undefined { if (!node) { return undefined; } if (typeof node === 'string') { return node; } if (typeof node === 'object' && '#text' in node) { return node['#text']; } return undefined; }

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