/* * Copyright 2025 Commonwealth Scientific and Industrial Research * Organisation (CSIRO) ABN 41 687 119 230. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import type { Questionnaire, QuestionnaireResponse, QuestionnaireResponseItem } from 'fhir/r4'; import { cacheTerminologyResult, createFhirPathContext, handleFhirPathResult, isExpressionCached } from './fhirpath'; import fhirpath from 'fhirpath'; import fhirpath_r4_model from 'fhirpath/fhir-context/r4'; import type { TargetConstraint } from '../interfaces/targetConstraint.interface'; import type { Variables } from '../interfaces'; interface EvaluateInitialTargetConstraintsParams { initialResponse: QuestionnaireResponse; initialResponseItemMap: Record; targetConstraints: Record; variables: Variables; existingFhirPathContext: Record; fhirPathTerminologyCache: Record; terminologyServerUrl: string; } export async function evaluateInitialTargetConstraints( params: EvaluateInitialTargetConstraintsParams ): Promise<{ initialTargetConstraints: Record; updatedFhirPathContext: Record; fhirPathTerminologyCache: Record; }> { const { initialResponse, initialResponseItemMap, targetConstraints, variables, existingFhirPathContext, terminologyServerUrl } = params; let { fhirPathTerminologyCache } = params; const fhirPathEvalResult = await createFhirPathContext( initialResponse, initialResponseItemMap, variables, existingFhirPathContext, fhirPathTerminologyCache, terminologyServerUrl ); const updatedFhirPathContext = fhirPathEvalResult.fhirPathContext; fhirPathTerminologyCache = fhirPathEvalResult.fhirPathTerminologyCache; for (const key in targetConstraints) { const initialValue = targetConstraints[key].isInvalid; const expression = targetConstraints[key].valueExpression?.expression; if (!expression) { continue; } if (isExpressionCached(expression, fhirPathTerminologyCache)) { continue; } try { const fhirPathResult = fhirpath.evaluate( {}, expression, updatedFhirPathContext, fhirpath_r4_model, { async: true, terminologyUrl: terminologyServerUrl } ); const result = await handleFhirPathResult(fhirPathResult); // Update targetConstraints if length of result array > 0 // Per FHIR spec, constraint expressions evaluate to true when the constraint is *met* (valid), so isInvalid = !result // Only update when current isInvalid value differs from the new value, otherwise it will result in an infinite loop as per #733 if (result.length > 0 && initialValue !== !result[0] && typeof result[0] === 'boolean') { targetConstraints[key].isInvalid = !result[0]; } // Update isInvalid value to false if no result is returned (intersect handled separately below) if (result.length === 0 && !expression.includes('intersect') && initialValue !== false) { targetConstraints[key].isInvalid = false; } // handle intersect edge case - evaluate() returns empty array if result is false (constraint violated) if (expression.includes('intersect') && result.length === 0 && initialValue !== true) { targetConstraints[key].isInvalid = true; } // If fhirPathResult is an async terminology call, cache the result if (fhirPathResult instanceof Promise) { cacheTerminologyResult(expression, result, fhirPathTerminologyCache); } } catch (e) { console.warn(e.message, `Target Constraint Key: ${key}\nExpression: ${expression}`); } } return { initialTargetConstraints: targetConstraints, updatedFhirPathContext, fhirPathTerminologyCache }; } export async function evaluateTargetConstraints( fhirPathContext: Record, fhirPathTerminologyCache: Record, targetConstraints: Record, terminologyServerUrl: string ): Promise<{ isUpdated: boolean; updatedTargetConstraints: Record; }> { let isUpdated = false; for (const key in targetConstraints) { const initialValue = targetConstraints[key].isInvalid; const expression = targetConstraints[key].valueExpression?.expression; if (!expression) { continue; } if (isExpressionCached(expression, fhirPathTerminologyCache)) { continue; } try { const fhirPathResult = fhirpath.evaluate({}, expression, fhirPathContext, fhirpath_r4_model, { async: true, terminologyUrl: terminologyServerUrl }); const result = await handleFhirPathResult(fhirPathResult); // Update targetConstraints if length of result array > 0 // Per FHIR spec, constraint expressions evaluate to true when the constraint is *met* (valid), so isInvalid = !result // Only update when current isInvalid value differs from the new value, otherwise it will result in an infinite loop as per #733 if (result.length > 0 && initialValue !== !result[0] && typeof result[0] === 'boolean') { targetConstraints[key].isInvalid = !result[0]; isUpdated = true; } // Update isInvalid value to false if no result is returned (intersect handled separately below) if (result.length === 0 && !expression.includes('intersect') && initialValue !== false) { targetConstraints[key].isInvalid = false; isUpdated = true; } // handle intersect edge case - evaluate() returns empty array if result is false (constraint violated) if (expression.includes('intersect') && result.length === 0 && initialValue !== true) { targetConstraints[key].isInvalid = true; isUpdated = true; } // If fhirPathResult is an async terminology call, cache the result if (fhirPathResult instanceof Promise) { cacheTerminologyResult(expression, result, fhirPathTerminologyCache); } } catch (e) { console.warn(e.message, `Target Constraint Key: ${key}\nExpression: ${expression}`); } } return { isUpdated: isUpdated, updatedTargetConstraints: targetConstraints }; } export function readTargetConstraintLocationLinkIds( questionnaire: Questionnaire, targetConstraints: Record ) { const targetConstraintLinkIds: Record = {}; for (const [targetConstraintKey, targetConstraint] of Object.entries(targetConstraints)) { if (targetConstraint.location) { // location overrides item-level linkId: resolve via FHIRPath and store the result const targetConstraintLinkId = getTargetConstraintLocationLinkId( questionnaire, targetConstraint.location ); if (targetConstraintLinkId) { // Add to targetConstraintLinkIds // If targetConstraintLinkId is not in targetConstraintLinkIds, create an array if (targetConstraintLinkIds[targetConstraintLinkId]) { targetConstraintLinkIds[targetConstraintLinkId].push(targetConstraintKey); } else { targetConstraintLinkIds[targetConstraintLinkId] = [targetConstraintKey]; } // Add to targetConstraint.linkId targetConstraints[targetConstraintKey].linkId = targetConstraintLinkId; } } else if (targetConstraint.linkId) { // No location — constraint was defined on the item directly, linkId set during extraction if (targetConstraintLinkIds[targetConstraint.linkId]) { targetConstraintLinkIds[targetConstraint.linkId].push(targetConstraintKey); } else { targetConstraintLinkIds[targetConstraint.linkId] = [targetConstraintKey]; } } } return targetConstraintLinkIds; } /** * Reads a value from a Questionnaire based on a FHIRPath location string. * @param questionnaire - The Questionnaire resource. * @param location - The FHIRPath location string. * @returns - The matched linkId, or null if no match is found. */ function getTargetConstraintLocationLinkId( questionnaire: Questionnaire, location: string ): string | null { try { const fhirPathResult = fhirpath.evaluate(questionnaire, location, {}, fhirpath_r4_model, { async: false }); if (fhirPathResult.length > 0) { const singleResult = fhirPathResult[0]; if ( typeof singleResult === 'object' && singleResult.linkId && typeof singleResult.linkId === 'string' ) { return singleResult.linkId; } } } catch (e) { console.warn(e.message, `Target Constraint Location: ${location}}`); } return null; }