Skip to main content
Glama
merge-matching-patients.ts14.1 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import { createReference, deepClone, getQuestionnaireAnswers, getReferenceString, resolveId } from '@medplum/core'; import type { BotEvent, MedplumClient, WithId } from '@medplum/core'; import type { Bundle, Identifier, Patient, QuestionnaireResponse, QuestionnaireResponseItemAnswer, Reference, RiskAssessment, } from '@medplum/fhirtypes'; /** * Handler function to process incoming BotEvent containing a QuestionnaireResponse for potential patient record merges. * * Because this bot only considers a single (source, target) match, there is a risk of race conditions * across multiple merge operations. Managing race conditions over record merges is a known hard problem, * and n practice, this should be handled at the workflow level. * * There are a few different ways to handle such race conditions: * - Modify the merge bot to operate on all RiskAssessments assigned to a given target patient * rather than on each RiskAssessment individually. This would keep the merges sequential within the Bot. * - Make sure all matches for a given target patient are reviewed by the same person. * Have the reviewer-facing UI force the reviewer to handle all merges for a given target in sequence * * @param medplum - The Medplum client instance. * @param event - The BotEvent containing the QuestionnaireResponse. * */ export async function handler(medplum: MedplumClient, event: BotEvent<QuestionnaireResponse>): Promise<void> { // Extract answers from the QuestionnaireResponse. const responses = getQuestionnaireAnswers(event.input); // Get the reference to the RiskAssessment from the answers. const riskAssessmentReference = event.input.subject as QuestionnaireResponseItemAnswer; // If there's no valid RiskAssessment reference in the response, throw an error. if (!riskAssessmentReference) { throw new Error('Invalid input. Expected RiskAssessment reference'); } const riskAssessment = (await medplum.readReference(riskAssessmentReference)) as RiskAssessment; const targetReference = riskAssessment.basis?.[0] as Reference<Patient>; const srcReference = riskAssessment.subject as Reference<Patient>; if (!targetReference || !srcReference) { throw new Error( `Undefined references target: ${JSON.stringify(targetReference, null, 2)} src: ${JSON.stringify( srcReference, null, 2 )}` ); } // If merge is disabled based on the questionnaire's answer, terminate the handler early. // Reasons for disabling merge include: // - The patient records are not duplicates. // - The patient records are duplicates, but the user does not want to merge them. const mergeDisabled = responses['disableMerge']?.valueBoolean; if (mergeDisabled) { await addToDoNotMatchList(medplum, srcReference, targetReference); await addToDoNotMatchList(medplum, targetReference, srcReference); return; } // Read the source and target Patient resource const targetPatient = await medplum.readReference(targetReference); const sourcePatient = await medplum.readReference(srcReference); const patients = linkPatientRecords(sourcePatient, targetPatient); // Copy some data from the source patient to the target, depending on the user input let fieldUpdates = {} as Partial<Patient>; const appendName = responses['appendName']?.valueBoolean; const appendAddress = responses['appendAddress']?.valueBoolean; if (appendName) { fieldUpdates = { ...fieldUpdates, name: [...(targetPatient.name ?? []), ...(sourcePatient.name ?? [])] }; } if (appendAddress) { fieldUpdates = { ...fieldUpdates, address: [...(targetPatient.address ?? []), ...(sourcePatient.address ?? [])] }; } const mergedPatients = mergePatientRecords(patients.src, patients.target, fieldUpdates); // Update clinical data to point to the target resource // To improve efficiency, consider grouping these requests into a batch transaction (http://hl7.org/fhir/R4/http.html#transaction) await rewriteClinicalDataReferences(medplum, mergedPatients.src, mergedPatients.target); // We might delete the source patient record if we don't want to continue to have a duplicate of an existing patient // despite the fact that it is an inactive record. const deleteSource = responses['deleteSource']?.valueBoolean; if (deleteSource === true) { await medplum.deleteResource('Patient', mergedPatients.src.id as string); } else { // If we don't delete the source patient record, we need to update it to be inactive. await medplum.updateResource<Patient>(mergedPatients.src); } // Update the target patient record with the merged data, and have it as the master record. await medplum.updateResource<Patient>(mergedPatients.target); } // start-block linkPatientRecords interface MergedPatients { readonly src: WithId<Patient>; readonly target: WithId<Patient>; } /** * Links two patient records indicating one replaces the other. * * @param src - The source patient record which is being replaced. * @param target - The target patient record which will replace the source. * @returns - Object containing updated source and target patient records with their links. */ export function linkPatientRecords(src: WithId<Patient>, target: WithId<Patient>): MergedPatients { const targetCopy = deepClone(target); const targetLinks = targetCopy.link ?? []; targetLinks.push({ other: createReference(src), type: 'replaces' }); const srcCopy = deepClone(src); const srcLinks = srcCopy.link ?? []; srcLinks.push({ other: createReference(target), type: 'replaced-by' }); return { src: { ...srcCopy, link: srcLinks, active: false }, target: { ...targetCopy, link: targetLinks } }; } // end-block linkPatientRecords // start-block unLinkPatientRecords /** * Unlink two patient that have been merged * * @param src - The source patient record which is marked as replaced. * @param target - The target patient marked as the master record. * @returns - Object containing updated source and target patient records with their links. */ export function unlinkPatientRecords(src: WithId<Patient>, target: WithId<Patient>): MergedPatients { const targetCopy = deepClone(target); const srcCopy = deepClone(src); // Filter out links from the target to the source targetCopy.link = targetCopy.link?.filter((link) => resolveId(link.other) !== src.id); // Filter out links from the source to the target srcCopy.link = srcCopy.link?.filter((link) => resolveId(link.other) !== target.id); // If the source record is no longer replaced, make it active again if (!srcCopy.link?.filter((link) => link.type === 'replaced-by')?.length) { srcCopy.active = true; } return { src: srcCopy, target: targetCopy }; } // end-block unLinkPatientRecords // start-block mergeIdentifiers /** * Merges contact information (identifiers) of two patient records, where the source patient record will be marked as * an old record. The target patient record will be overwritten with the merged data and will be the master record. * * @param src - The source patient record. * @param target - The target patient record. * @param fields - Optional additional fields to be merged. * @returns - Object containing the original source and the merged target patient records. */ export function mergePatientRecords( src: WithId<Patient>, target: WithId<Patient>, fields?: Partial<Patient> ): MergedPatients { const targetCopy = deepClone(target); const mergedIdentifiers = targetCopy.identifier ?? []; const srcIdentifiers = src.identifier ?? []; // Check for conflicts between the source and target records' identifiers for (const srcIdentifier of srcIdentifiers) { const targetIdentifier = mergedIdentifiers?.find((identifier) => identifier.system === srcIdentifier.system); // If the targetRecord has an identifier with the same system, check if source and target agree on the identifier value if (targetIdentifier) { if (targetIdentifier.value !== srcIdentifier.value) { throw new Error(`Mismatched identifier for system ${srcIdentifier.system}`); } } // If this identifier is not present on the target, add it to the merged record and mark it as 'old' else { mergedIdentifiers.push({ ...srcIdentifier, use: 'old' } as Identifier); } } targetCopy.identifier = mergedIdentifiers; const targetMerged = { ...targetCopy, ...fields }; return { src: src, target: targetMerged }; } // end-block mergeIdentifiers /** * Returns true if both the source and target patients are part of the same merged "cluster" of records. * There are three cases to handle: * 1. Both the source and target patients share the same master record (i.e. both replaced by the same record) * 2. The target record is the master record (i.e. target 'replaces' source) * 3. The source record is the master record (i.e. source 'replaces' target) * * @param src - The source patient record * @param target - The target patient record * @returns true if both the source and target patients are part of the same merged "cluster" of records. */ export function patientsAlreadyMerged(src: Patient, target: Patient): boolean { const srcMaster = src.link?.find((link) => link.type === 'replaced-by')?.other; const targetMaster = target.link?.find((link) => link.type === 'replaced-by')?.other; // Case 1: Both patients share the same master record if (srcMaster && targetMaster && resolveId(srcMaster) === resolveId(targetMaster)) { return true; } // Case 2: The target record is the master record if (resolveId(srcMaster) === target.id) { if (!target.link?.find((link) => link.type === 'replaces' && resolveId(link.other) === src.id)) { throw new Error( `Target Patient ${getReferenceString(target)} missing a 'replaces' link to ${getReferenceString(src)}` ); } return true; } if (resolveId(targetMaster) === src.id) { if (!src.link?.find((link) => link.type === 'replaces' && resolveId(link.other) === target.id)) { throw new Error( `Source Patient ${getReferenceString(target)} missing a 'replaces' link to ${getReferenceString(src)}` ); } return true; } return false; } // start-block updateReferences /** * Rewrites all references to source patient to the target patient for all clinical data. * Uses the Patient $everything operation to efficiently retrieve all resources in the patient compartment. * * @param medplum - The MedplumClient * @param sourcePatient - Source `Patient` resource. After this operation, no resources will refer to this `Patient` * @param targetPatient - Target `Patient` resource. After this operation, all clinical resources will refer to this `Patient` */ export async function rewriteClinicalDataReferences( medplum: MedplumClient, sourcePatient: WithId<Patient>, targetPatient: WithId<Patient> ): Promise<void> { const sourceReference = getReferenceString(sourcePatient); const targetReference = getReferenceString(targetPatient); // Use readPatientEverything to efficiently retrieve all resources in the patient compartment // This operation supports pagination, so we need to follow 'next' links to get all resources let bundle: Bundle = await medplum.readPatientEverything(sourcePatient.id); // Process all pages of results while (bundle) { // Process all entries in the current bundle if (bundle.entry) { for (const entry of bundle.entry) { const resource = entry.resource; if (!resource) { continue; } // Skip the Patient resource itself (only if it matches the source patient ID) if (resource.resourceType === 'Patient' && resource.id === sourcePatient.id) { continue; } // Rewrite references from source to target replaceReferences(resource, sourceReference, targetReference); await medplum.updateResource(resource); } } // Check for next page const nextLink = bundle.link?.find((link) => link.relation === 'next'); if (nextLink?.url) { // Fetch the next page bundle = await medplum.get<Bundle>(nextLink.url); } else { // No more pages break; } } } /** * Recursive function to search for all references to the source resource, and translate them to the target resource * @param obj - A FHIR resource or element * @param srcReference - The reference string referring to the source resource * @param targetReference - The reference string referring to the target resource */ function replaceReferences(obj: any, srcReference: string, targetReference: string): void { for (const key in obj) { if (typeof obj[key] === 'object' && obj[key] !== null) { replaceReferences(obj[key], srcReference, targetReference); } else if (typeof obj[key] === 'string' && obj[key] === srcReference) { obj[key] = targetReference; } } } // end-block updateReferences // start-block doNotMatch /** * Adds a patient to the 'doNotMatch' list for a given patient list. * * @param medplum - The Medplum client instance. * @param subject - Reference to the patient list. * @param patientAdded - Reference to the patient being added to the 'doNotMatch' list. * * @returns - Returns a promise that resolves when the operation is completed. */ async function addToDoNotMatchList( medplum: MedplumClient, subject: Reference<Patient>, patientAdded: Reference<Patient> ): Promise<void> { const list = await medplum.searchOne('List', { subject: subject.reference, code: 'http://example.org/listType|doNotMatch', }); if (list) { const entries = list.entry ?? []; entries.push({ item: patientAdded }); await medplum.updateResource({ ...list, entry: entries }); return; } console.warn('No doNotMatch list found for patient: ' + subject.reference); } // end-block doNotMatch

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