diff --git a/.env b/.env index d6cb984..b70c02f 100644 --- a/.env +++ b/.env @@ -17,6 +17,14 @@ VITE_LAUNCH_URL = http://localhost:4040/launch VITE_PASSWORD = alice VITE_PATIENT_FHIR_QUERY = Patient?_sort=identifier&_count=12 VITE_PIMS_SERVER = http://localhost:5051/ncpdp/script +VITE_USE_PHARMACY_INTERMEDIARY = false +VITE_PHARMACY_INTERMEDIARY = http://localhost:3003/ncpdp/script +VITE_PPA_LOCATOR_MODE = true +VITE_PPA_SUBSTITUTION_ALLOWED = true +VITE_PPA_ENDPOINTS = [{"enabled":true,"id":"Pharmacy123","name":"PIMS Pharmacy A","url":"http://localhost:5051/ncpdp/script","scriptUrl":"http://localhost:5051/ncpdp/script"},{"enabled":true,"id":"Pharmacy456","name":"PIMS Pharmacy B","url":"http://localhost:5151/ncpdp/script","scriptUrl":"http://localhost:5151/ncpdp/script"}] +VITE_PPA_DEFAULT_STATE = MA +VITE_PPA_DEFAULT_POSTAL_CODE = +VITE_PPA_GENERIC_CANDIDATES = [{"baseNdc":"65597-407-20","ndc":"99999-407-20","display":"Pexidartinib Hydrochloride 200 MG Oral Capsule"}] VITE_PUBLIC_KEYS = http://localhost:3000/request-generator/.well-known/jwks.json VITE_REALM = ClientFhirServer VITE_RESPONSE_EXPIRATION_DAYS = 30 diff --git a/README.md b/README.md index 9e86b65..a8ce84e 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,14 @@ Following are a list of modifiable paths: | VITE_PASSWORD | `alice` | The default password for logging in as the default user, defined by VITE_USER. This should be changed if using a different default user. | | VITE_PATIENT_FHIR_QUERY | `Patient?_sort=identifier&_count=12` | The FHIR query the app makes when searching for patients in the EHR. This should be modified if a different behavior is desired by the apps patient selection popup. This can also be modified directly in the app's settings. | | VITE_PIMS_SERVER | `http://localhost:5051/ncpdp/script` | The Pharmacy System endpoint for submitting medications. This should be changed depending on which pharmacy system you want to connect with. | +| VITE_USE_PHARMACY_INTERMEDIARY | `false` | When true, NCPDP messages, including NewRx and Product Availability JSON, are sent to the pharmacy intermediary endpoint. | +| VITE_PHARMACY_INTERMEDIARY | `http://localhost:3003/ncpdp/script` | Pharmacy intermediary NCPDP Script endpoint for NewRx and Product Availability routing. | +| VITE_PPA_LOCATOR_MODE | `true` | When true, Product Availability lookup searches all enabled pharmacy endpoints and equivalent products before choosing a pharmacy/product. | +| VITE_PPA_SUBSTITUTION_ALLOWED | `true` | Default value for whether Product Availability requests allow substitute products. | +| VITE_PPA_ENDPOINTS | `[{...Pharmacy123...},{...Pharmacy456...}]` | JSON array used to seed the PPA Pharmacy Endpoints settings table. Local entries should point `url`/`scriptUrl` at each pharmacy `/ncpdp/script` endpoint. | +| VITE_PPA_DEFAULT_STATE | `MA` | Fallback pickup state for Product Availability requests when the selected patient does not have an address state. | +| VITE_PPA_DEFAULT_POSTAL_CODE | empty | Fallback pickup ZIP code for Product Availability requests when the selected patient does not have an address postal code. | +| VITE_PPA_GENERIC_CANDIDATES | `[{...65597-407-20 -> 99999-407-20...}]` | Advanced JSON override for direct-mode product equivalence candidates. In normal local testing, use the built-in Pexidartinib mapping and configure pharmacy endpoints in the settings table. | | VITE_PUBLIC_KEYS | `http://localhost:3000/request-generator/.well-known/jwks.json` | The endpoint which contains the public keys for authentication with the REMS admin. Should be changed if the keys are moved elsewhere. | | VITE_REALM | `ClientFhirServer` | The Keycloak realm to use. Only relevant is using Keycloak as an authentication server. This only affects direct logins like through the Patient Portal, not SMART launches like opening the app normally. | | VITE_RESPONSE_EXPIRATION_DAYS | `30` | The number of days old a Questionnaire Response can be before it is ignored and filtered out. This ensures the patient search excludes outdated or obsolete prior sessions from creating clutter. | diff --git a/dockerRunnerDev.sh b/dockerRunnerDev.sh index 4198943..3a16415 100755 --- a/dockerRunnerDev.sh +++ b/dockerRunnerDev.sh @@ -1,9 +1,9 @@ #!/bin/sh # Handle closing application on signal interrupt (ctrl + c) -trap 'kill $CONTINUOUS_INSTALL_PID $SERVER_PID; gradle --stop; exit' INT +trap 'kill $CONTINUOUS_INSTALL_PID $SERVER_PID 2>/dev/null; exit' INT TERM -mkdir logs +mkdir -p logs # Reset log file content for new application boot echo "*** Logs for continuous installer ***" > ./logs/installer.log echo "*** Logs for 'npm run start' ***" > ./logs/runner.log @@ -13,23 +13,31 @@ echo "starting application in watch mode..." # Start the continious build listener process echo "starting continuous installer..." -npm install +if [ ! -d node_modules ]; then + npm install | tee ./logs/installer.log +fi -( package_modify_time=$(stat -c %Y package.json) -package_lock_modify_time=$(stat -c %Y package-lock.json) +( file_hash() { + cksum "$1" 2>/dev/null || echo "missing $1" +} + +package_hash=$(file_hash package.json) +package_lock_hash=$(file_hash package-lock.json) while sleep 1 do - new_package_modify_time=$(stat -c %Y package.json) - new_package_lock_modify_time=$(stat -c %Y package-lock.json) + new_package_hash=$(file_hash package.json) + new_package_lock_hash=$(file_hash package-lock.json) - if [[ "$package_modify_time" != "$new_package_modify_time" ]] || [[ "$package_lock_modify_time" != "$new_package_lock_modify_time" ]] + if [ "$package_hash" != "$new_package_hash" ] || [ "$package_lock_hash" != "$new_package_lock_hash" ] then echo "running npm install..." npm install | tee ./logs/installer.log + new_package_hash=$(file_hash package.json) + new_package_lock_hash=$(file_hash package-lock.json) fi - package_modify_time=$new_package_modify_time - package_lock_modify_time=$new_package_lock_modify_time + package_hash=$new_package_hash + package_lock_hash=$new_package_lock_hash done ) & CONTINUOUS_INSTALL_PID=$! @@ -40,4 +48,3 @@ done ) & CONTINUOUS_INSTALL_PID=$! wait $CONTINUOUS_INSTALL_PID $SERVER_PID EXIT_CODE=$? echo "application exited with exit code $EXIT_CODE..." - diff --git a/src/components/RequestBox/RequestBox.jsx b/src/components/RequestBox/RequestBox.jsx index 7fbd0b5..87329c8 100644 --- a/src/components/RequestBox/RequestBox.jsx +++ b/src/components/RequestBox/RequestBox.jsx @@ -1,7 +1,7 @@ -import { Button, ButtonGroup, Grid } from '@mui/material'; +import { Button, ButtonGroup, Checkbox, FormControlLabel, Grid } from '@mui/material'; import _ from 'lodash'; import { SettingsContext } from '../../containers/ContextProvider/SettingsProvider.jsx'; -import { useEffect, useState, useContext } from 'react'; +import { useEffect, useRef, useState, useContext } from 'react'; import buildNewRxRequest from '../../util/buildScript.2017071.js'; import MuiAlert from '@mui/material/Alert'; import Snackbar from '@mui/material/Snackbar'; @@ -18,16 +18,36 @@ import { getMedicationSpecificEtasuUrl, getPatientFirstAndLastName } from '../../util/util.js'; +import { + applySelectedProductToMedicationRequest, + buildPpaRequest, + getNdcCoding, + getSelectedProductFromPpaResponse, + getMedicationDisplay +} from '../../util/buildPpa.js'; +import { + getEquivalentPpaCandidates, + getPpaProductConfigByNdc +} from '../../util/ppaProductEquivalents.js'; import './request.css'; import axios from 'axios'; +const initialPpaState = { + checking: false, + results: [], + selectedProduct: null, + selectedPharmacy: null, + message: '' +}; + const RequestBox = props => { const [state, setState] = useState({ gatherCount: 0, response: {}, submittedRx: false }); - const [globalState] = useContext(SettingsContext); + const [ppaState, setPpaState] = useState(initialPpaState); + const [globalState, , updateSetting] = useContext(SettingsContext); const { prefetchedResources, @@ -42,17 +62,33 @@ const RequestBox = props => { smartAppUrl, client, pimsUrl, - prefetchCompleted + prefetchCompleted, + selectRequestResource } = props; const emptyField = empty; + const lastRequestId = useRef(request?.id || ''); + + const getPrefetchObject = () => { + if (prefetchedResources instanceof Map) { + return Object.fromEntries(prefetchedResources); + } + return { ...(prefetchedResources || {}) }; + }; + + const getPrefetchForRequest = selectedRequest => + prepPrefetch({ + ...getPrefetchObject(), + request: selectedRequest + }); const submitPatientView = () => { - submitInfo(prepPrefetch(prefetchedResources), null, patient, PATIENT_VIEW); + submitInfo(prepPrefetch(getPrefetchObject()), null, patient, PATIENT_VIEW); }; - const submitOrderSign = request => { - if (!_.isEmpty(request)) { - submitInfo(prepPrefetch(prefetchedResources), request, patient, ORDER_SIGN); + const submitOrderSign = async () => { + const requestForSign = await getRequestForSelectedProduct(); + if (!_.isEmpty(requestForSign)) { + submitInfo(getPrefetchForRequest(requestForSign), requestForSign, patient, ORDER_SIGN); } }; @@ -216,6 +252,235 @@ const RequestBox = props => { }; }; + const parseJsonSetting = (value, fallback) => { + try { + const parsed = JSON.parse(value || ''); + return Array.isArray(parsed) ? parsed : fallback; + } catch { + return fallback; + } + }; + + const getConfiguredPharmacies = () => { + const fallback = [ + { + id: 'Pharmacy123', + name: 'PIMS Pharmacy A', + url: 'http://localhost:5051/ncpdp/script', + scriptUrl: 'http://localhost:5051/ncpdp/script' + } + ]; + + const configured = Array.isArray(globalState.ppaPharmacyEndpoints) + ? globalState.ppaPharmacyEndpoints + : parseJsonSetting(globalState.ppaEndpointList, fallback); + + return configured.filter(pharmacy => pharmacy.enabled !== false && pharmacy.id && pharmacy.url); + }; + + const getPpaCandidates = () => { + const ndcCoding = getNdcCoding(request); + const baseProduct = { + ndc: ndcCoding?.code, + display: ndcCoding?.display || getMedicationDisplay(request) + }; + if (!globalState.ppaSubstitutionAllowed) { + return baseProduct.ndc ? [baseProduct] : []; + } + + const genericCandidates = getEquivalentPpaCandidates(baseProduct.ndc); + + return [baseProduct, ...genericCandidates].filter(candidate => candidate.ndc); + }; + + const getAvailabilityResultLabel = result => { + const requested = result.requestedProduct?.display || 'Requested product'; + const selected = result.selectedProduct?.display; + const product = + selected && selected !== requested ? `${requested} -> ${selected}` : requested; + + return `${result.pharmacy?.name}: ${product} - ${result.reasonCode}`; + }; + + const getSelectedPharmacyScriptEndpoint = () => { + if (!ppaState.selectedPharmacy?.url) { + return pimsUrl; + } + + return ( + ppaState.selectedPharmacy.scriptUrl || + ppaState.selectedPharmacy.ncpdpScriptUrl || + ppaState.selectedPharmacy.url + ); + }; + + const getPharmacyPpaEndpoint = pharmacy => + pharmacy?.scriptUrl || pharmacy?.ncpdpScriptUrl || pharmacy?.url || pimsUrl; + + const buildMedicationRequestIdFromTemplate = template => + template?.replace('{patientId}', patient?.id || ''); + + const getSelectedMedicationRequestId = selectedProduct => { + const productConfig = getPpaProductConfigByNdc(selectedProduct?.ndc); + return ( + selectedProduct?.medicationRequestId || + productConfig?.medicationRequestId || + buildMedicationRequestIdFromTemplate( + selectedProduct?.medicationRequestIdTemplate || + productConfig?.medicationRequestIdTemplate + ) + ); + }; + + useEffect(() => { + const currentRequestId = request?.id || ''; + if (lastRequestId.current && lastRequestId.current !== currentRequestId) { + const selectedRequestId = getSelectedMedicationRequestId(ppaState.selectedProduct); + if (!selectedRequestId || selectedRequestId !== currentRequestId) { + setPpaState(initialPpaState); + } + } + lastRequestId.current = currentRequestId; + }, [request?.id]); + + const getRequestForProduct = async selectedProduct => { + if (!selectedProduct?.ndc) { + return request; + } + + const selectedMedicationRequestId = getSelectedMedicationRequestId(selectedProduct); + if (selectedMedicationRequestId && selectedMedicationRequestId === request?.id) { + return request; + } + + if (selectedMedicationRequestId && client) { + try { + return await client.request(`MedicationRequest/${selectedMedicationRequestId}`); + } catch (error) { + console.log( + `Unable to load selected MedicationRequest/${selectedMedicationRequestId}; keeping current request`, + error + ); + return request; + } + } + + if (selectedMedicationRequestId) { + return request; + } + + return applySelectedProductToMedicationRequest(request, selectedProduct); + }; + + const getRequestForSelectedProduct = () => getRequestForProduct(ppaState.selectedProduct); + + const selectRequestForProduct = async selectedProduct => { + const selectedRequest = await getRequestForProduct(selectedProduct); + if (selectedRequest?.id && selectedRequest.id !== request?.id) { + selectRequestResource?.(selectedRequest); + } + return selectedRequest; + }; + + const getPatientPreferenceState = () => + patient?.address?.find(address => address?.state)?.state || globalState.ppaDefaultState || 'MA'; + + const getPatientPreferencePostalCode = () => + patient?.address?.find(address => address?.postalCode)?.postalCode || + globalState.ppaDefaultPostalCode || + undefined; + + const checkAvailability = async () => { + setPpaState({ + ...initialPpaState, + checking: true + }); + + const pharmacies = globalState.ppaLocatorMode + ? getConfiguredPharmacies() + : getConfiguredPharmacies().slice(0, 1); + const candidates = getPpaCandidates(); + const results = []; + const substitutionAllowed = Boolean(globalState.ppaSubstitutionAllowed); + + for (const pharmacy of pharmacies) { + for (const candidate of candidates) { + const endpoint = globalState.usePharmacyIntermediary + ? globalState.pharmacyIntermediaryUrl + : getPharmacyPpaEndpoint(pharmacy); + const ppaRequest = buildPpaRequest({ + patient, + practitioner: getPrefetchObject().practitioner, + medicationRequest: request, + pharmacy, + substitutionAllowed, + patientPreferenceState: getPatientPreferenceState(), + patientPreferencePostalCode: getPatientPreferencePostalCode(), + overrideProduct: candidate + }); + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(ppaRequest) + }); + const responseJson = await response.json().catch(() => null); + if (!response.ok || !responseJson) { + throw new Error(`PPA lookup failed with HTTP ${response.status}`); + } + const result = getSelectedProductFromPpaResponse(responseJson, { + ...candidate, + pharmacyId: pharmacy.id, + pharmacyName: pharmacy.name + }); + const resultWithContext = { + ...result, + pharmacy, + requestedProduct: candidate, + endpoint, + httpStatus: response.status + }; + results.push(resultWithContext); + + if (result.approved) { + await selectRequestForProduct(result.selectedProduct); + setPpaState(prev => ({ + ...prev, + checking: false, + results, + selectedProduct: result.selectedProduct, + selectedPharmacy: pharmacy, + message: `${result.reasonCode}: ${result.selectedProduct.display || candidate.display} available at ${pharmacy.name}` + })); + return; + } + } catch (error) { + results.push({ + approved: false, + reasonCode: 'ERROR', + pharmacy, + requestedProduct: candidate, + endpoint, + error: error.message + }); + } + } + } + + setPpaState(prev => ({ + ...prev, + checking: false, + results, + selectedProduct: null, + selectedPharmacy: null, + message: 'No configured pharmacy returned an approved availability response' + })); + }; + /** * Send NewRx for new Medication to the Pharmacy Information System (PIMS) */ @@ -223,14 +488,15 @@ const RequestBox = props => { // Use intermediary or direct based on toggle const ncpdpEndpoint = globalState.usePharmacyIntermediary ? globalState.pharmacyIntermediaryUrl - : pimsUrl; + : getSelectedPharmacyScriptEndpoint(); console.log('Sending NewRx to: ' + ncpdpEndpoint); console.log('Getting case number'); - const medication = createMedicationFromMedicationRequest(request); + const requestForDispense = await getRequestForSelectedProduct(); + const medication = createMedicationFromMedicationRequest(requestForDispense); const body = makeBody(medication); const standardEtasuUrl = getMedicationSpecificEtasuUrl( - getDrugCodeableConceptFromMedicationRequest(request), + getDrugCodeableConceptFromMedicationRequest(requestForDispense), globalState ); let caseNumber = ''; @@ -253,10 +519,11 @@ const RequestBox = props => { // build the NewRx Message var newRx = buildNewRxRequest( - prefetchedResources.patient, - prefetchedResources.practitioner, - request, - caseNumber + getPrefetchObject().patient, + getPrefetchObject().practitioner, + requestForDispense, + caseNumber, + ppaState.selectedPharmacy?.id || 'Pharmacy 123' ); console.log('Prepared NewRx:'); @@ -278,7 +545,7 @@ const RequestBox = props => { console.log('Successfully sent NewRx to PIMS'); // create the MedicationDispense - var medicationDispense = createMedicationDispenseFromMedicationRequest(request); + var medicationDispense = createMedicationDispenseFromMedicationRequest(requestForDispense); console.log('Create MedicationDispense:'); console.log(medicationDispense); @@ -312,6 +579,7 @@ const RequestBox = props => { const disableSendToCRD = isOrderNotSelected() || loading; const disableSendRx = isOrderNotSelected() || loading; + const disableCheckAvailability = isOrderNotSelected() || loading || ppaState.checking; const disableLaunchSmartOnFhir = isPatientNotSelected(); return ( @@ -340,10 +608,37 @@ const RequestBox = props => { - + +
+ updateSetting('ppaSubstitutionAllowed', event.target.checked)} + /> + } + label="Allow substitutions" + /> + {ppaState.message && {ppaState.message}} +
+ {ppaState.results.length > 0 && ( +
+ {ppaState.results.map((result, index) => ( +
+ {getAvailabilityResultLabel(result)} +
+ ))} +
+ )} { dispatch({ type: actionTypes.resetSettings }); }; + const getPpaPharmacyEndpoints = () => + Array.isArray(state.ppaPharmacyEndpoints) ? state.ppaPharmacyEndpoints : []; + + const updatePpaPharmacyEndpoint = (index, value) => { + updateSetting( + 'ppaPharmacyEndpoints', + getPpaPharmacyEndpoints().map((endpoint, i) => + i === index ? { ...endpoint, ...value } : endpoint + ) + ); + }; + + const addPpaPharmacyEndpoint = () => { + updateSetting('ppaPharmacyEndpoints', [ + ...getPpaPharmacyEndpoints(), + { + enabled: true, + id: 'PharmacyNew', + name: 'New Pharmacy', + url: 'http://localhost:5051/ncpdp/script', + scriptUrl: 'http://localhost:5051/ncpdp/script' + } + ]); + }; + + const deletePpaPharmacyEndpoint = index => { + updateSetting( + 'ppaPharmacyEndpoints', + getPpaPharmacyEndpoints().filter((_endpoint, i) => i !== index) + ); + }; + const resetPims = ({ pimsUrl }) => () => { @@ -520,90 +552,207 @@ const SettingsSection = props => { } ]; - let firstCheckbox = true; - let showBreak = true; + const pharmacySettingKeys = new Set([ + 'includePharmacyInPreFetch', + 'pharmacyId', + 'usePharmacyIntermediary', + 'pharmacyIntermediaryUrl', + 'pimsUrl', + 'ppaLocatorMode', + 'ppaSubstitutionAllowed', + 'ppaPharmacyEndpoints', + 'ppaDefaultState', + 'ppaDefaultPostalCode' + ]); + const primaryFields = fieldHeaders.filter(({ key }) => !pharmacySettingKeys.has(key)); + const pharmacyFields = fieldHeaders.filter(({ key }) => pharmacySettingKeys.has(key)); + + const renderSettingFields = fields => { + let firstCheckbox = true; + + return fields.map(({ key, type, display }) => { + switch (type) { + case 'input': + return ( + + {(state.useDefaultUser && key === 'defaultUser') || key !== 'defaultUser' ? ( +
+ updateSetting(key, event.target.value)} + sx={{ width: '100%' }} + /> +
+ ) : ( + '' + )} +
+ ); + case 'check': { + const showBreak = firstCheckbox; + firstCheckbox = false; + return ( + + {showBreak ? : ''} + + updateSetting(key, event.target.checked)} + /> + } + label={display} + /> + + + ); + } + case 'dropdown': + return ( + + + + Hook to send when selecting a patient + + + + + ); + case 'ppaEndpointTable': + return ( + + + + + + Enabled + Pharmacy ID + Display Name + PPA URL + SCRIPT URL + + + + + {getPpaPharmacyEndpoints().map((endpoint, index) => ( + + + + updatePpaPharmacyEndpoint(index, { + enabled: event.target.checked + }) + } + /> + + + + updatePpaPharmacyEndpoint(index, { id: event.target.value }) + } + sx={{ width: '100%' }} + /> + + + + updatePpaPharmacyEndpoint(index, { name: event.target.value }) + } + sx={{ width: '100%' }} + /> + + + + updatePpaPharmacyEndpoint(index, { url: event.target.value }) + } + sx={{ width: '100%' }} + /> + + + + updatePpaPharmacyEndpoint(index, { scriptUrl: event.target.value }) + } + sx={{ width: '100%' }} + /> + + + + deletePpaPharmacyEndpoint(index)} + size="large" + > + + + + + + ))} + + + + + + +
+
+
+ ); + default: + return ( +
+

{display}

+
+ ); + } + }); + }; return ( - {fieldHeaders.map(({ key, type, display }) => { - switch (type) { - case 'input': - return ( - - {(state['useDefaultUser'] && key === 'defaultUser') || key != 'defaultUser' ? ( -
- updateSetting(key, event.target.value)} - sx={{ width: '100%' }} - /> -
- ) : ( - '' - )} -
- ); - case 'check': - if (firstCheckbox) { - firstCheckbox = false; - showBreak = true; - } else { - showBreak = false; - } - return ( - - {showBreak ? : ''} - - updateSetting(key, event.target.checked)} - /> - } - label={display} - /> - - - ); - case 'dropdown': - return ( - - - - - Hook to send when selecting a patient - - - - - - ); - default: - return ( -
-

{display}

-
- ); - } - })} + {renderSettingFields(primaryFields)}
@@ -622,6 +771,7 @@ const SettingsSection = props => { Medication Display Medication RxNorm Code + Medication NDC Hook / Endpoint REMS Admin URL {/* This empty TableCell corresponds to the add and delete @@ -662,6 +812,20 @@ const SettingsSection = props => { sx={{ width: '100%' }} /> + + + dispatch({ + type: actionTypes.updateCdsHookSetting, + settingId: key, + value: { ndc: event.target.value } + }) + } + sx={{ width: '100%' }} + /> +