Skip to main content
Glama
utils.ts19.7 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import type { TypedValue } from '@medplum/core'; import { HTTP_HL7_ORG, PropertyType, capitalize, deepClone, evalFhirPathTyped, getExtension, getReferenceString, getTypedPropertyValueWithoutSchema, normalizeErrorString, splitN, toJsBoolean, toTypedValue, typedValueToString, } from '@medplum/core'; import type { Encounter, Questionnaire, QuestionnaireItem, QuestionnaireItemAnswerOption, QuestionnaireItemEnableWhen, QuestionnaireItemInitial, QuestionnaireResponse, QuestionnaireResponseItem, QuestionnaireResponseItemAnswer, Reference, ResourceType, } from '@medplum/fhirtypes'; export const QuestionnaireItemType = { group: 'group', display: 'display', question: 'question', boolean: 'boolean', decimal: 'decimal', integer: 'integer', date: 'date', dateTime: 'dateTime', time: 'time', string: 'string', text: 'text', url: 'url', choice: 'choice', openChoice: 'open-choice', attachment: 'attachment', reference: 'reference', quantity: 'quantity', } as const; export type QuestionnaireItemType = (typeof QuestionnaireItemType)[keyof typeof QuestionnaireItemType]; export const QUESTIONNAIRE_ITEM_CONTROL_URL = `${HTTP_HL7_ORG}/fhir/StructureDefinition/questionnaire-itemControl`; export const QUESTIONNAIRE_REFERENCE_FILTER_URL = `${HTTP_HL7_ORG}/fhir/StructureDefinition/questionnaire-referenceFilter`; export const QUESTIONNAIRE_REFERENCE_RESOURCE_URL = `${HTTP_HL7_ORG}/fhir/StructureDefinition/questionnaire-referenceResource`; export const QUESTIONNAIRE_VALIDATION_ERROR_URL = `${HTTP_HL7_ORG}/fhir/StructureDefinition/questionnaire-validationError`; export const QUESTIONNAIRE_ENABLED_WHEN_EXPRESSION_URL = `${HTTP_HL7_ORG}/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-enableWhenExpression`; export const QUESTIONNAIRE_CALCULATED_EXPRESSION_URL = `${HTTP_HL7_ORG}/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-calculatedExpression`; export const QUESTIONNAIRE_SIGNATURE_REQUIRED_URL = `${HTTP_HL7_ORG}/fhir/StructureDefinition/questionnaire-signatureRequired`; export const QUESTIONNAIRE_SIGNATURE_RESPONSE_URL = `${HTTP_HL7_ORG}/fhir/StructureDefinition/questionnaireresponse-signature`; /** * Returns true if the item is a choice question. * @param item - The questionnaire item to check. * @returns True if the item is a choice question, false otherwise. */ export function isChoiceQuestion(item: QuestionnaireItem): boolean { return item.type === 'choice' || item.type === 'open-choice'; } /** * Returns true if the questionnaire item is enabled based on the enableWhen conditions or expression. * @param item - The questionnaire item to check. * @param questionnaireResponse - The questionnaire response to check against. * @returns True if the question is enabled, false otherwise. */ export function isQuestionEnabled( item: QuestionnaireItem, questionnaireResponse: QuestionnaireResponse | undefined ): boolean { const extensionResult = isQuestionEnabledViaExtension(item, questionnaireResponse); if (extensionResult !== undefined) { return extensionResult; } return isQuestionEnabledViaEnabledWhen(item, questionnaireResponse); } /** * Returns true if the questionnaire item is enabled via an extension expression. * * An expression that returns a boolean value for whether to enable the item. * If the expression does not resolve to a boolean, it is considered an error in the design of the Questionnaire. * Form renderer behavior is undefined. * Some tools may attempt to force the value to be a boolean (e.g. is it a non-empty collection, non-null, non-zero - if so, then true). * * See: https://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-enableWhenExpression.html * * @param item - The questionnaire item to check. * @param questionnaireResponse - The questionnaire response to check against. * @returns True if the question is enabled via an extension expression, false otherwise. */ function isQuestionEnabledViaExtension( item: QuestionnaireItem, questionnaireResponse: QuestionnaireResponse | undefined ): boolean | undefined { const extension = getExtension(item, QUESTIONNAIRE_ENABLED_WHEN_EXPRESSION_URL); if (questionnaireResponse && extension) { const expression = extension.valueExpression?.expression; if (expression) { const value = toTypedValue(questionnaireResponse); const result = evalFhirPathTyped(expression, [value], { '%resource': value }); return toJsBoolean(result); } } return undefined; } /** * Returns true if the questionnaire item is enabled based on the enableWhen conditions. * * See: https://hl7.org/fhir/R4/questionnaire-definitions.html#Questionnaire.item.enableWhen * See: https://hl7.org/fhir/R4/questionnaire-definitions.html#Questionnaire.item.enableBehavior * * @param item - The questionnaire item to check. * @param questionnaireResponse - The questionnaire response to check against. * @returns True if the question is enabled based on the enableWhen conditions, false otherwise. */ function isQuestionEnabledViaEnabledWhen( item: QuestionnaireItem, questionnaireResponse: QuestionnaireResponse | undefined ): boolean { if (!item.enableWhen) { return true; } const enableBehavior = item.enableBehavior ?? 'any'; for (const enableWhen of item.enableWhen) { const actualAnswers = getByLinkId(questionnaireResponse?.item, enableWhen.question as string); if (enableWhen.operator === 'exists' && !enableWhen.answerBoolean && !actualAnswers?.length) { if (enableBehavior === 'any') { return true; } else { continue; } } const { anyMatch, allMatch } = checkAnswers(enableWhen, actualAnswers, enableBehavior); if (enableBehavior === 'any' && anyMatch) { return true; } if (enableBehavior === 'all' && !allMatch) { return false; } } return enableBehavior !== 'any'; } /** * Evaluates the calculated expressions in a questionnaire. * Updates response item answers in place with the calculated values. * * See: https://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-calculatedExpression.html * * @param items - The questionnaire items to evaluate. * @param response - The questionnaire response to evaluate against. * @param responseItems - The response items to update. */ export function evaluateCalculatedExpressionsInQuestionnaire( items: QuestionnaireItem[], response: QuestionnaireResponse, responseItems: QuestionnaireResponseItem[] | undefined = response.item ): void { for (const item of items) { const responseItem = responseItems?.find((r) => r.linkId === item.linkId); if (responseItem) { evaluateQuestionnaireItemCalculatedExpressions(response, item, responseItem); if (item.item && responseItem.item) { // If the item has nested items, evaluate their calculated expressions as well evaluateCalculatedExpressionsInQuestionnaire(item.item, response, responseItem.item); } } } } function evaluateQuestionnaireItemCalculatedExpressions( response: QuestionnaireResponse, item: QuestionnaireItem, responseItem: QuestionnaireResponseItem ): void { try { const calculatedValue = evaluateCalculatedExpression(item, response); if (!calculatedValue) { return; } const answer = typedValueToResponseItem(item, calculatedValue); if (!answer) { return; } responseItem.answer = [answer]; } catch (error) { responseItem.extension = [ { url: QUESTIONNAIRE_VALIDATION_ERROR_URL, valueString: `Expression evaluation failed: ${normalizeErrorString(error)}`, }, ]; } } const questionnaireItemTypesAllowedPropertyTypes: Record<string, string[]> = { [QuestionnaireItemType.boolean]: [PropertyType.boolean], [QuestionnaireItemType.date]: [PropertyType.date], [QuestionnaireItemType.dateTime]: [PropertyType.dateTime], [QuestionnaireItemType.time]: [PropertyType.time], [QuestionnaireItemType.url]: [PropertyType.string, PropertyType.uri, PropertyType.url], [QuestionnaireItemType.attachment]: [PropertyType.Attachment], [QuestionnaireItemType.reference]: [PropertyType.Reference], [QuestionnaireItemType.quantity]: [PropertyType.Quantity], [QuestionnaireItemType.decimal]: [PropertyType.decimal, PropertyType.integer], [QuestionnaireItemType.integer]: [PropertyType.decimal, PropertyType.integer], } as const; export function typedValueToResponseItem( item: QuestionnaireItem, value: TypedValue ): QuestionnaireResponseItemAnswer | undefined { if (!item.type) { return undefined; } if (item.type === QuestionnaireItemType.choice || item.type === QuestionnaireItemType.openChoice) { // Choice and open-choice items can have multiple answer options return { [`value${capitalize(value.type)}`]: value.value }; } if (item.type === QuestionnaireItemType.string || item.type === QuestionnaireItemType.text) { // Always coerce string values to valueString if (typeof value.value === 'string') { return { valueString: value.value }; } return undefined; } const allowedPropertyTypes = questionnaireItemTypesAllowedPropertyTypes[item.type]; if (allowedPropertyTypes?.includes(value.type)) { // Use the questionnaire item type to determine the response item type return { [`value${capitalize(item.type)}`]: value.value }; } return undefined; } function evaluateCalculatedExpression( item: QuestionnaireItem, response: QuestionnaireResponse | undefined ): TypedValue | undefined { if (!response) { return undefined; } const extension = getExtension(item, QUESTIONNAIRE_CALCULATED_EXPRESSION_URL); if (extension) { const expression = extension.valueExpression?.expression; if (expression) { const value = toTypedValue(response); const result = evalFhirPathTyped(expression, [value], { '%resource': value }); return result.length !== 0 ? result[0] : undefined; } } return undefined; } export function getNewMultiSelectValues( selected: string[], propertyName: string, item: QuestionnaireItem ): QuestionnaireResponseItemAnswer[] { const result: QuestionnaireResponseItemAnswer[] = []; for (const selectedStr of selected) { const option = item.answerOption?.find( (candidate) => typedValueToString(getItemAnswerOptionValue(candidate)) === selectedStr ); if (option) { const optionValue = getItemAnswerOptionValue(option); if (optionValue) { result.push({ [propertyName]: optionValue.value }); } } } return result; } function getByLinkId( responseItems: QuestionnaireResponseItem[] | undefined, linkId: string ): QuestionnaireResponseItemAnswer[] | undefined { if (!responseItems) { return undefined; } for (const response of responseItems) { if (response.linkId === linkId) { return response.answer; } if (response.item) { const nestedAnswer = getByLinkId(response.item, linkId); if (nestedAnswer) { return nestedAnswer; } } } return undefined; } function evaluateMatch(actualAnswer: TypedValue | undefined, expectedAnswer: TypedValue, operator?: string): boolean { // We handle exists separately since its so different in terms of comparisons than the other mathematical operators if (operator === 'exists') { // if actualAnswer is not undefined, then exists: true passes // if actualAnswer is undefined, then exists: false passes return !!actualAnswer === expectedAnswer.value; } else if (!actualAnswer) { return false; } else { // `=` and `!=` should be treated as the FHIRPath `~` and `!~` // All other operators should be unmodified const fhirPathOperator = operator === '=' || operator === '!=' ? operator?.replace('=', '~') : operator; const [{ value }] = evalFhirPathTyped(`%actualAnswer ${fhirPathOperator} %expectedAnswer`, [actualAnswer], { '%actualAnswer': actualAnswer, '%expectedAnswer': expectedAnswer, }); return value; } } function checkAnswers( enableWhen: QuestionnaireItemEnableWhen, answers: QuestionnaireResponseItemAnswer[] | undefined, enableBehavior: 'any' | 'all' ): { anyMatch: boolean; allMatch: boolean } { const actualAnswers = answers || []; const expectedAnswer = getItemEnableWhenValueAnswer(enableWhen); let anyMatch = false; let allMatch = true; for (const actualAnswerValue of actualAnswers) { const actualAnswer = getResponseItemAnswerValue(actualAnswerValue); const { operator } = enableWhen; const match = evaluateMatch(actualAnswer, expectedAnswer, operator); if (match) { anyMatch = true; } else { allMatch = false; } if (enableBehavior === 'any' && anyMatch) { break; } } return { anyMatch, allMatch }; } export function getQuestionnaireItemReferenceTargetTypes(item: QuestionnaireItem): ResourceType[] | undefined { const extension = getExtension(item, QUESTIONNAIRE_REFERENCE_RESOURCE_URL); if (!extension) { return undefined; } if (extension.valueCode !== undefined) { return [extension.valueCode] as ResourceType[]; } if (extension.valueCodeableConcept) { return extension.valueCodeableConcept?.coding?.map((c) => c.code) as ResourceType[]; } return undefined; } export function setQuestionnaireItemReferenceTargetTypes( item: QuestionnaireItem, targetTypes: ResourceType[] | undefined ): QuestionnaireItem { const result = deepClone(item); let extension = getExtension(result, QUESTIONNAIRE_REFERENCE_RESOURCE_URL); if (!targetTypes || targetTypes.length === 0) { if (extension) { result.extension = result.extension?.filter((e) => e !== extension); } return result; } if (!extension) { result.extension ??= []; extension = { url: QUESTIONNAIRE_REFERENCE_RESOURCE_URL }; result.extension.push(extension); } if (targetTypes.length === 1) { extension.valueCode = targetTypes[0]; delete extension.valueCodeableConcept; } else { extension.valueCodeableConcept = { coding: targetTypes.map((t) => ({ code: t })) }; delete extension.valueCode; } return result; } /** * Returns the reference filter for the given questionnaire item. * @see https://build.fhir.org/ig/HL7/fhir-extensions/StructureDefinition-questionnaire-referenceFilter-definitions.html * @param item - The questionnaire item to get the reference filter for. * @param subject - Optional subject reference. * @param encounter - Optional encounter reference. * @returns The reference filter as a map of key/value pairs. */ export function getQuestionnaireItemReferenceFilter( item: QuestionnaireItem, subject: Reference | undefined, encounter: Reference<Encounter> | undefined ): Record<string, string> | undefined { const extension = getExtension(item, QUESTIONNAIRE_REFERENCE_FILTER_URL); if (!extension?.valueString) { return undefined; } // Replace variables let filter = extension.valueString; if (subject?.reference) { filter = filter.replaceAll('$subj', subject.reference); } if (encounter?.reference) { filter = filter.replaceAll('$encounter', encounter.reference); } // Parse the valueString into a map const result: Record<string, string> = {}; const parts = filter.split('&'); for (const part of parts) { const [key, value] = splitN(part, '=', 2); result[key] = value; } return result; } export function buildInitialResponse( questionnaire: Questionnaire, questionnaireResponse?: QuestionnaireResponse ): QuestionnaireResponse { const response: QuestionnaireResponse = { resourceType: 'QuestionnaireResponse', questionnaire: questionnaire.url ?? getReferenceString(questionnaire), item: buildInitialResponseItems(questionnaire.item, questionnaireResponse?.item), status: 'in-progress', }; return response; } function buildInitialResponseItems( items: QuestionnaireItem[] | undefined, responseItems: QuestionnaireResponseItem[] | undefined ): QuestionnaireResponseItem[] | undefined { if (!items) { return undefined; } const result = []; for (const item of items) { if (item.type === QuestionnaireItemType.display) { // Display items do not have response items, so we skip them. continue; } const existingResponseItems = responseItems?.filter((responseItem) => responseItem.linkId === item.linkId); if (existingResponseItems && existingResponseItems?.length > 0) { for (const existingResponseItem of existingResponseItems) { // Update existing response item existingResponseItem.id = existingResponseItem.id ?? generateId(); existingResponseItem.text = existingResponseItem.text ?? item.text; existingResponseItem.item = buildInitialResponseItems(item.item, existingResponseItem.item); existingResponseItem.answer = buildInitialResponseAnswer(item, existingResponseItem); result.push(existingResponseItem); } } else { // Add new response item result.push(buildInitialResponseItem(item)); } } return result; } export function buildInitialResponseItem(item: QuestionnaireItem): QuestionnaireResponseItem { return { id: generateId(), linkId: item.linkId, text: item.text, item: buildInitialResponseItems(item.item, undefined), answer: buildInitialResponseAnswer(item), }; } let nextId = 1; function generateId(): string { return 'id-' + nextId++; } function buildInitialResponseAnswer( item: QuestionnaireItem, responseItem?: QuestionnaireResponseItem ): QuestionnaireResponseItemAnswer[] | undefined { if (item.type === QuestionnaireItemType.display || item.type === QuestionnaireItemType.group) { return undefined; } if (responseItem?.answer && responseItem.answer.length > 0) { // If the response item already has answers, return them as is. return responseItem.answer; } if (item.initial && item.initial.length > 0) { // If the item has initial values, return them as answers. // This works because QuestionnaireItemInitial and QuestionnaireResponseItemAnswer // have the same properties. return item.initial.map((initial) => ({ ...initial })); } if (item.answerOption) { return item.answerOption .filter((option) => option.initialSelected) .map((option) => ({ ...option, initialSelected: undefined })); } // Otherwise, return undefined to indicate no initial answers. return undefined; } export function getItemInitialValue(initial: QuestionnaireItemInitial | undefined): TypedValue { return getTypedPropertyValueWithoutSchema( { type: 'QuestionnaireItemInitial', value: initial }, 'value' ) as TypedValue; } export function getItemAnswerOptionValue(option: QuestionnaireItemAnswerOption): TypedValue { return getTypedPropertyValueWithoutSchema( { type: 'QuestionnaireItemAnswerOption', value: option }, 'value' ) as TypedValue; } export function getItemEnableWhenValueAnswer(enableWhen: QuestionnaireItemEnableWhen): TypedValue { return getTypedPropertyValueWithoutSchema( { type: 'QuestionnaireItemEnableWhen', value: enableWhen }, 'answer' ) as TypedValue; } export function getResponseItemAnswerValue(answer: QuestionnaireResponseItemAnswer): TypedValue | undefined { return getTypedPropertyValueWithoutSchema({ type: 'QuestionnaireResponseItemAnswer', value: answer }, 'value') as | TypedValue | 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