Skip to main content
Glama
useHealthGorillaLabOrder.ts22 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import type { ResourceArray } from '@medplum/core'; import { createReference, deepClone, getExtensionValue, getIdentifier, isResource } from '@medplum/core'; import type { Bundle, Coverage, Location, Organization, Patient, Practitioner, Questionnaire, Reference, ServiceRequest, } from '@medplum/fhirtypes'; import type { BillingInformation, DiagnosisCodeableConcept, LabOrderServiceRequest, LabOrderTestMetadata, LabOrganization, TestCoding, } from '@medplum/health-gorilla-core'; import { HEALTH_GORILLA_SYSTEM, MEDPLUM_HEALTH_GORILLA_LAB_ORDER_PROFILE, createLabOrderBundle, isReferenceOfType, normalizeAoeQuestionnaire, questionnaireItemIterator, validateLabOrderInputs, } from '@medplum/health-gorilla-core'; import { useMedplum } from '@medplum/react'; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; import type { AOESearch, LabSearch, TestSearch } from './autocomplete-endpoint'; import { getAutocompleteSearchFunction } from './autocomplete-endpoint'; import type { HealthGorillaLabOrderState, TestMetadata, UseHealthGorillaLabOrderReturn, } from './HealthGorillaLabOrderContext'; export type UseHealthGorillaLabOrderOptions = { /** Patient the tests are ordered for. */ patient: Patient | (Reference<Patient> & { reference: string }) | undefined; /** * The physician who requests diagnostic procedures for the patient and responsible for the order. * The Practitioner MUST have an NPI identifier with the system http://hl7.org/fhir/sid/us-npi. */ requester: Practitioner | (Reference<Practitioner> & { reference: string }) | undefined; /** For multi-location practices, optionally specify the location from which the order is being placed */ requestingLocation?: Location | Organization | (Reference<Location | Organization> & { reference: string }); }; export type EditableLabOrderTestMetadata = Pick<LabOrderTestMetadata, 'priority' | 'notes' | 'aoeResponses'>; const EDITABLE_TEST_METADATA_KEYS: (keyof LabOrderTestMetadata)[] = ['priority', 'notes', 'aoeResponses']; const INITIAL_TESTS: TestCoding[] = []; const INITIAL_DIAGNOSES: DiagnosisCodeableConcept[] = []; const INITIAL_TEST_METADATA = {}; const INITIAL_BILLING_INFORMATION: BillingInformation = {}; type TestsReducerState = { // Don't use any optional fields here, e.g. `selectedTests?: TestCoding[]` since that would cause // some loss of type safety in the reducer function by allowing the reducer to potentially return // incomplete state if a field is forgotten about. Instead, always use explicity `| undefined` for // fields that may legitimately be undefined in the state. selectedTests: TestCoding[]; testMetadata: Record<string, TestMetadata | undefined>; specimenCollectedDateTime: Date | undefined; }; type AddTestAction = { type: 'add'; test: TestCoding }; type RemoveTestAction = { type: 'remove'; test: TestCoding }; type SetTestsAction = { type: 'set'; tests: TestCoding[] }; type UpdateMetadataAction = { type: 'updateMetadata'; test: TestCoding; partialMetadata: Partial<EditableLabOrderTestMetadata>; }; type AoeLoadedAction = { type: 'aoeLoaded'; testAoes: [TestCoding, Questionnaire | undefined][] }; type SpecimentCollectionDateChangeAction = { type: 'specimenCollectionDateChange'; newDate: Date | undefined }; type AoeErrorAction = { type: 'aoeError'; tests: TestCoding[] }; type TestsAction = | AddTestAction | RemoveTestAction | SetTestsAction | UpdateMetadataAction | AoeLoadedAction | SpecimentCollectionDateChangeAction | AoeErrorAction; function testsReducer(prev: TestsReducerState, action: TestsAction): TestsReducerState { switch (action.type) { case 'add': return addTest(prev, action); case 'remove': return removeTest(prev, action); case 'set': return setTests(prev, action); case 'updateMetadata': return updateMetadata(prev, action); case 'aoeLoaded': return aoeLoaded(prev, action); case 'aoeError': return aoeError(prev, action); case 'specimenCollectionDateChange': return specimenCollectionDateChange(prev, action); default: action satisfies never; return prev; } } function addTest(prev: TestsReducerState, action: AddTestAction): TestsReducerState { if (prev.selectedTests.some((test) => test.code === action.test.code)) { return prev; } return { ...prev, selectedTests: [...prev.selectedTests, action.test], testMetadata: { ...prev.testMetadata, [action.test.code]: { aoeStatus: 'loading' }, }, }; } function removeTest(prev: TestsReducerState, action: RemoveTestAction): TestsReducerState { return { ...prev, selectedTests: prev.selectedTests.filter((test) => test.code !== action.test.code), testMetadata: Object.fromEntries(Object.entries(prev.testMetadata).filter(([code]) => code !== action.test.code)), }; } function setTests(prev: TestsReducerState, action: SetTestsAction): TestsReducerState { return { ...prev, selectedTests: action.tests, testMetadata: Object.fromEntries( action.tests.map((test) => { return [test.code, prev.testMetadata[test.code] ?? { aoeStatus: 'loading' }]; }) ), }; } function updateMetadata(prev: TestsReducerState, action: UpdateMetadataAction): TestsReducerState { const { test, partialMetadata } = action; const prevTestMetadata = prev.testMetadata[test.code]; if (!prev.selectedTests.some((t) => t.code === test.code) || !prevTestMetadata) { console.warn(`Cannot update metadata for test ${test.code} since it is not a selected tests`); return prev; } let allowedUpdates = partialMetadata; const restrictedKeys = Object.keys(partialMetadata).filter((k) => !EDITABLE_TEST_METADATA_KEYS.includes(k as any)); if (restrictedKeys.length > 0) { console.warn(`Cannot update test metadata fields: ${restrictedKeys.join(', ')}`); allowedUpdates = Object.fromEntries(Object.entries(partialMetadata).filter(([k]) => !restrictedKeys.includes(k))); } return { ...prev, testMetadata: { ...prev.testMetadata, [test.code]: { ...prevTestMetadata, ...allowedUpdates }, }, }; } function aoeLoaded(prev: TestsReducerState, action: AoeLoadedAction): TestsReducerState { let newTestMetadata: TestsReducerState['testMetadata'] | undefined; for (const [test, aoeQuestionnaire] of action.testAoes) { const prevMetadata = prev.testMetadata[test.code]; if (!prevMetadata) { continue; } newTestMetadata ??= { ...prev.testMetadata }; newTestMetadata[test.code] = { ...prevMetadata, aoeStatus: aoeQuestionnaire ? 'loaded' : 'none', aoeQuestionnaire: aoeQuestionnaire && (updateAoeQuestionnaireRequiredItems(aoeQuestionnaire, prev.specimenCollectedDateTime) ?? aoeQuestionnaire), }; } if (!newTestMetadata) { return prev; } return { ...prev, testMetadata: newTestMetadata, }; } function aoeError(prev: TestsReducerState, action: AoeErrorAction): TestsReducerState { let newTestMetadata: TestsReducerState['testMetadata'] | undefined; for (const test of action.tests) { const prevMetadata = prev.testMetadata[test.code]; if (!prevMetadata || prevMetadata.aoeStatus !== 'loading') { continue; } newTestMetadata ??= { ...prev.testMetadata }; newTestMetadata[test.code] = { ...prevMetadata, aoeStatus: 'error', }; } if (!newTestMetadata) { return prev; } return { ...prev, testMetadata: newTestMetadata, }; } function specimenCollectionDateChange( prev: TestsReducerState, action: SpecimentCollectionDateChangeAction ): TestsReducerState { // check if we can reuse the previous testMetadata object to avoid unnecessary downstream re-renders let newTestMetadata: TestsReducerState['testMetadata'] | undefined; for (const test of prev.selectedTests) { const prevTestMetadata = prev.testMetadata[test.code]; if (!prevTestMetadata) { continue; } if (prevTestMetadata.aoeQuestionnaire) { const newAoeQ = updateAoeQuestionnaireRequiredItems(prevTestMetadata.aoeQuestionnaire, action.newDate); if (newAoeQ) { newTestMetadata ??= { ...prev.testMetadata }; newTestMetadata[test.code] = { ...prevTestMetadata, aoeQuestionnaire: newAoeQ }; } } } return { ...prev, // If no changes to AOE questionnaire item.required, reuse the previous object testMetadata: newTestMetadata ?? prev.testMetadata, specimenCollectedDateTime: action.newDate, }; } export function useHealthGorillaLabOrder(opts: UseHealthGorillaLabOrderOptions): UseHealthGorillaLabOrderReturn { const medplum = useMedplum(); const [performingLab, privateSetPerformingLab] = useState<LabOrganization | undefined>(); const [performingLabAccountNumber, privateSetPerformingLabAccountNumber] = useState<string | undefined>(); const [testsAndMetadata, dispatchTests] = useReducer(testsReducer, { specimenCollectedDateTime: undefined, selectedTests: INITIAL_TESTS, testMetadata: INITIAL_TEST_METADATA, }); const [diagnoses, privateSetDiagnoses] = useState<DiagnosisCodeableConcept[]>(INITIAL_DIAGNOSES); const [billingInformation, setBillingInformation] = useState<BillingInformation>(INITIAL_BILLING_INFORMATION); const [orderNotes, privateSetOrderNotes] = useState<string | undefined>(); const healthGorillaAutocomplete = useMemo(() => { return getAutocompleteSearchFunction(medplum, { system: 'https://www.medplum.com/integrations/bot-identifier', value: 'health-gorilla-labs/autocomplete', }); }, [medplum]); const fetchAoeQuestionnaire = useCallback( async (testCode: TestCoding): Promise<Questionnaire | undefined> => { const response = await healthGorillaAutocomplete<AOESearch>({ type: 'aoe', testCode }); const questionnaire = response.result; if (questionnaire) { normalizeAoeQuestionnaire(questionnaire); } return response.result; }, [healthGorillaAutocomplete] ); const lastSelectedTests = useRef<TestCoding[]>([]); useEffect(() => { // effect only runs when selectedTests changes if (lastSelectedTests.current === testsAndMetadata.selectedTests) { return; } lastSelectedTests.current = testsAndMetadata.selectedTests; const testCodingAoesBeingFetched: TestCoding[] = []; async function fetchNeededAoeQuestions(): Promise<[TestCoding, Questionnaire | undefined][]> { const promises: Promise<[TestCoding, Questionnaire | undefined]>[] = []; for (const test of testsAndMetadata.selectedTests) { const metadata = testsAndMetadata.testMetadata[test.code]; if (metadata?.aoeStatus !== 'loading') { continue; } testCodingAoesBeingFetched.push(test); promises.push( new Promise((resolve, reject) => { fetchAoeQuestionnaire(test) .then((questionnaire) => { resolve([test, questionnaire]); }) .catch(reject); }) ); } return Promise.all(promises); } fetchNeededAoeQuestions() .then((results) => { if (results.length === 0) { return; } dispatchTests({ type: 'aoeLoaded', testAoes: results }); }) .catch((err) => { console.error('Error fetching AOE questions', err); dispatchTests({ type: 'aoeError', tests: testCodingAoesBeingFetched }); }); }, [fetchAoeQuestionnaire, testsAndMetadata]); const patientRef = useMemo<(Reference<Patient> & { reference: string }) | undefined>(() => { if (isReferenceOfType('Patient', opts.patient)) { return opts.patient; } else if (isResource(opts.patient)) { return createReference(opts.patient); } else { return undefined; } }, [opts.patient]); const requesterRef = useMemo<(Reference<Practitioner> & { reference: string }) | undefined>(() => { if (isReferenceOfType('Practitioner', opts.requester)) { return opts.requester; } else if (isResource(opts.requester)) { return createReference(opts.requester) as Reference<Practitioner> & { reference: string }; } else { opts.requester satisfies undefined; return undefined; } }, [opts.requester]); const requestingLocationRef = useMemo< (Reference<Location | Organization> & { reference: string }) | undefined >(() => { if ( isReferenceOfType('Location', opts.requestingLocation) || isReferenceOfType('Organization', opts.requestingLocation) ) { return opts.requestingLocation; } else if (isResource(opts.requestingLocation)) { return createReference(opts.requestingLocation); } else { return undefined; } }, [opts.requestingLocation]); const state: HealthGorillaLabOrderState = useMemo(() => { const cloned = deepClone({ performingLab, performingLabAccountNumber, selectedTests: testsAndMetadata.selectedTests, testMetadata: testsAndMetadata.testMetadata, diagnoses, billingInformation, specimenCollectedDateTime: testsAndMetadata.specimenCollectedDateTime, orderNotes, }); // specimenCollectedDateTime is a Date object which is serialized in deepClone if (cloned.specimenCollectedDateTime) { cloned.specimenCollectedDateTime = new Date(cloned.specimenCollectedDateTime); } return cloned; }, [ performingLab, performingLabAccountNumber, testsAndMetadata.selectedTests, testsAndMetadata.testMetadata, testsAndMetadata.specimenCollectedDateTime, diagnoses, billingInformation, orderNotes, ]); const getActivePatientCoverages = useCallback(async (): Promise<ResourceArray<Coverage>> => { if (!patientRef) { const emptyResult: Coverage[] = []; return Object.assign(emptyResult, { bundle: { resourceType: 'Bundle', type: 'searchset', entry: [], total: 0 } as Bundle<Coverage>, }); } const coverageSearch = new URLSearchParams({ patient: patientRef.reference, status: 'active', }); const coverages = (await medplum.searchResources('Coverage', coverageSearch)).sort((a, b) => { return (a.order ?? Number.POSITIVE_INFINITY) - (b.order ?? Number.POSITIVE_INFINITY); }); return coverages; }, [medplum, patientRef]); const updateBillingInformation = useCallback((billingInfo: Partial<BillingInformation>): void => { setBillingInformation((prev) => { return { ...prev, ...billingInfo }; }); }, []); const result: UseHealthGorillaLabOrderReturn = useMemo(() => { return { state, searchAvailableLabs: async (query: string) => { const response = await healthGorillaAutocomplete<LabSearch>({ type: 'lab', query }); // consider filtering to labs that have the https://www.healthgorilla.com/fhir/StructureDefinition/provider-compendium extension? return response.result; }, searchAvailableTests: async (query: string): Promise<TestCoding[]> => { if (!performingLab) { return []; } const hgLabId = getIdentifier(performingLab, HEALTH_GORILLA_SYSTEM); if (!hgLabId) { throw new Error('No Health Gorilla identifier found for performing lab'); } if (query.length === 0) { return []; } const response = await healthGorillaAutocomplete<TestSearch>({ type: 'test', query, labId: hgLabId }); const tests = response.result; return tests; }, setPerformingLab: (newLab: LabOrganization | undefined) => { privateSetPerformingLab(newLab); }, setPerformingLabAccountNumber: (newAccountNumber: string | undefined) => { privateSetPerformingLabAccountNumber(newAccountNumber); }, addTest: (test: TestCoding) => { dispatchTests({ type: 'add', test }); }, removeTest: (test: TestCoding) => { dispatchTests({ type: 'remove', test }); }, setTests: (newTests: TestCoding[]) => { dispatchTests({ type: 'set', tests: newTests }); }, updateTestMetadata: (test: TestCoding, partialMetadata: Partial<LabOrderTestMetadata>) => { dispatchTests({ type: 'updateMetadata', test, partialMetadata }); }, addDiagnosis(diagnosis: DiagnosisCodeableConcept) { privateSetDiagnoses((prev) => { if (prev.some((item) => toDiagnosisKey(item) === toDiagnosisKey(diagnosis))) { return prev; } return [...prev, diagnosis]; }); }, removeDiagnosis(diagnosis: DiagnosisCodeableConcept) { privateSetDiagnoses((prev) => { const idx = prev.findIndex((item) => toDiagnosisKey(item) === toDiagnosisKey(diagnosis)); if (idx === -1) { return prev; } const next = [...prev]; next.splice(idx, 1); return next; }); }, setDiagnoses: (newDiagnoses: DiagnosisCodeableConcept[]) => { privateSetDiagnoses(newDiagnoses); }, getActivePatientCoverages, updateBillingInformation, setSpecimenCollectedDateTime: (newDate: Date | undefined) => { dispatchTests({ type: 'specimenCollectionDateChange', newDate }); }, setOrderNotes: (newOrderNotes: string | undefined) => { // || instead of ?? so that empty string becomes undefined privateSetOrderNotes(newOrderNotes || undefined); }, validateOrder: () => { return validateLabOrderInputs({ patient: patientRef, requester: requesterRef, performingLab: state.performingLab, performingLabAccountNumber: state.performingLabAccountNumber, selectedTests: state.selectedTests, testMetadata: state.testMetadata, diagnoses: state.diagnoses, billingInformation: state.billingInformation, specimenCollectedDateTime: state.specimenCollectedDateTime, orderNotes: state.orderNotes, }); }, createOrderBundle: async (): Promise<{ transactionResponse: Bundle; serviceRequest: ServiceRequest }> => { const txn = createLabOrderBundle({ patient: patientRef, requester: requesterRef, requestingLocation: requestingLocationRef, performingLab: state.performingLab, performingLabAccountNumber: state.performingLabAccountNumber, selectedTests: state.selectedTests, testMetadata: state.testMetadata, diagnoses: state.diagnoses, billingInformation: state.billingInformation, specimenCollectedDateTime: state.specimenCollectedDateTime, orderNotes: state.orderNotes, }); const transactionResponse = await medplum.executeBatch(txn); let labOrderServiceRequest: LabOrderServiceRequest | undefined; for (const entry of transactionResponse?.entry ?? []) { if (!entry.response?.status.startsWith('2')) { throw new Error('Error creating lab order: Non-2XX status code in response entry', { cause: transactionResponse, }); } if (entry.resource?.meta?.profile?.includes(MEDPLUM_HEALTH_GORILLA_LAB_ORDER_PROFILE)) { labOrderServiceRequest = entry.resource as LabOrderServiceRequest; } } if (!labOrderServiceRequest) { throw new Error('Error creating lab order: Lab Order Service Request not found in response entries', { cause: transactionResponse, }); } return { transactionResponse, serviceRequest: labOrderServiceRequest }; }, }; }, [ getActivePatientCoverages, healthGorillaAutocomplete, medplum, patientRef, performingLab, requestingLocationRef, requesterRef, state, updateBillingInformation, ]); return result; } function toDiagnosisKey(diagnosis: DiagnosisCodeableConcept): string { return diagnosis.coding[0].code as string; } const REQUIRED_WHEN_SPECIMEN = 'https://www.healthgorilla.com/fhir/StructureDefinition/questionnaire-requiredwhenspecimen'; /** * Updates the required status of items in an AOE questionnaire based on whether a specimen is being collected. * @param questionnaire - The Questionnaire to update * @param specimenCollectedDateTime - The date and time the specimen was collected, if any * @returns A new Questionnaire with the required status of items updated, or undefined if no changes are needed */ function updateAoeQuestionnaireRequiredItems( questionnaire: Questionnaire, specimenCollectedDateTime: Date | undefined ): Questionnaire | undefined { let newQuestionnaire: Questionnaire | undefined; const requiredWhenSpecimen = Boolean(specimenCollectedDateTime); // first, check if the questionnaire needs any changes for (const item of questionnaireItemIterator(questionnaire.item)) { if (getExtensionValue(item, REQUIRED_WHEN_SPECIMEN) === true && Boolean(item.required) !== requiredWhenSpecimen) { // at least one item needs updating, so crete a clone to be updated rather than updating in place newQuestionnaire = deepClone(questionnaire); break; } } // if no changes are needed, return the original Questionnaire if (!newQuestionnaire) { return undefined; } // otherwise, update the cloned Questionnaire for (const item of questionnaireItemIterator(newQuestionnaire.item)) { if (getExtensionValue(item, REQUIRED_WHEN_SPECIMEN) === true) { if (Boolean(item.required) !== requiredWhenSpecimen) { item.required = requiredWhenSpecimen; } } } return newQuestionnaire; }

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