From fb770080ecae2151c9d1f6f85e9ab7ed80d92117 Mon Sep 17 00:00:00 2001 From: Mike Moyer <87040148+mmoyer-va@users.noreply.github.com> Date: Thu, 12 Dec 2024 17:31:25 -0500 Subject: [PATCH] MHV-65059 Error handling for BB download (#33535) * MHV-65059 Don't overwrite data if key is missing from the action payload * MHV-65059 Modified action to only call necessary APIs * MHV-65059 Harmonized flow for generating BB PDF * MHV-65059 Revert test data * MHV-65059 Started adding error-handling * MHV-65059 More work on error handling * MHV-65059 Fixed mismatched field * MHV-65059 Added error alert for failed APIs * MHV-65059 Fixed unit tests * MHV-65059 Added a test for failed calls * MHV-65059 Fixed URL for appointments --- .../actions/blueButtonReport.js | 81 +++++++++------- .../mhv-medical-records/api/MrApi.js | 22 ++--- .../DownloadRecords/DownloadFileType.jsx | 94 +++++++++++++------ .../DownloadRecords/MissingRecordsError.jsx | 41 ++++++++ .../containers/DownloadReportPage.jsx | 39 +++++++- .../reducers/blueButton.js | 62 ++++++------ .../tests/actions/blueButtonReport.spec.js | 15 +++ .../tests/reducers/blueButton.unit.spec.js | 5 +- .../mhv-medical-records/util/actionTypes.js | 2 + 9 files changed, 256 insertions(+), 105 deletions(-) create mode 100644 src/applications/mhv-medical-records/components/DownloadRecords/MissingRecordsError.jsx diff --git a/src/applications/mhv-medical-records/actions/blueButtonReport.js b/src/applications/mhv-medical-records/actions/blueButtonReport.js index 09320b333d4a..d2281cf15397 100644 --- a/src/applications/mhv-medical-records/actions/blueButtonReport.js +++ b/src/applications/mhv-medical-records/actions/blueButtonReport.js @@ -13,40 +13,58 @@ import { getAppointments, } from '../api/MrApi'; import { Actions } from '../util/actionTypes'; -import * as Constants from '../util/constants'; -import { addAlert } from './alerts'; + +export const clearFailedList = domain => dispatch => { + dispatch({ type: Actions.BlueButtonReport.CLEAR_FAILED, payload: domain }); +}; export const getBlueButtonReportData = (options = {}) => async dispatch => { - try { - const fetchMap = { - labs: getLabsAndTests, - notes: getNotes, - vaccines: getVaccineList, - allergies: getAllergies, - conditions: getConditions, - vitals: getVitalsList, - radiology: getMhvRadiologyTests, - medications: getMedications, - appointments: getAppointments, - demographics: getDemographicInfo, - militaryService: getMilitaryService, - patient: getPatient, - }; + const fetchMap = { + labsAndTests: getLabsAndTests, + notes: getNotes, + vaccines: getVaccineList, + allergies: getAllergies, + conditions: getConditions, + vitals: getVitalsList, + radiology: getMhvRadiologyTests, + medications: getMedications, + appointments: getAppointments, + demographics: getDemographicInfo, + militaryService: getMilitaryService, + patient: getPatient, + }; - const promises = Object.entries(fetchMap) - .filter(([key]) => options[key]) // Only include enabled fetches - .map(([key, fetchFn]) => fetchFn().then(response => ({ key, response }))); + const promises = Object.entries(fetchMap) + .filter(([key]) => options[key]) // Only include enabled fetches + .map(([key, fetchFn]) => + fetchFn() + .then(response => ({ key, response })) + .catch(error => { + const newError = new Error(error); + newError.key = key; + throw newError; + }), + ); - const results = await Promise.all(promises); + const results = await Promise.allSettled(promises); - results.forEach(({ key, response }) => { + results.forEach(({ status, value, reason }) => { + if (status === 'fulfilled') { + const { key, response } = value; switch (key) { - case 'labs': + case 'labsAndTests': dispatch({ type: Actions.LabsAndTests.GET_LIST, labsAndTestsResponse: response, }); break; + // TODO: Handle this with labs + case 'radiology': + dispatch({ + type: Actions.LabsAndTests.GET_LIST, + radiologyResponse: response, + }); + break; case 'notes': dispatch({ type: Actions.CareSummariesAndNotes.GET_LIST, @@ -77,12 +95,6 @@ export const getBlueButtonReportData = (options = {}) => async dispatch => { response, }); break; - case 'radiology': - dispatch({ - type: Actions.LabsAndTests.GET_LIST, - radiologyResponse: response, - }); - break; case 'medications': case 'appointments': case 'demographics': @@ -96,9 +108,10 @@ export const getBlueButtonReportData = (options = {}) => async dispatch => { default: break; } - }); - } catch (error) { - dispatch(addAlert(Constants.ALERT_TYPE_ERROR, error)); - throw error; - } + } else { + // Handle rejected promises + const { key } = reason; + dispatch({ type: Actions.BlueButtonReport.ADD_FAILED, payload: key }); + } + }); }; diff --git a/src/applications/mhv-medical-records/api/MrApi.js b/src/applications/mhv-medical-records/api/MrApi.js index 1560689f8061..ac874aaad4a1 100644 --- a/src/applications/mhv-medical-records/api/MrApi.js +++ b/src/applications/mhv-medical-records/api/MrApi.js @@ -26,7 +26,7 @@ export const getRefreshStatus = () => { }); }; -export const getLabsAndTests = () => { +export const getLabsAndTests = async () => { return apiRequest(`${apiBasePath}/medical_records/labs_and_tests`, { headers, }); @@ -62,7 +62,7 @@ export const getBbmiNotificationStatus = () => { }); }; -export const getMhvRadiologyTests = () => { +export const getMhvRadiologyTests = async () => { return apiRequest(`${apiBasePath}/medical_records/radiology`, { headers, }); @@ -83,7 +83,7 @@ export const getMhvRadiologyDetails = async id => { return findMatchingPhrAndCvixStudies(id, phrResponse, cvixResponse); }; -export const getNotes = () => { +export const getNotes = async () => { return apiRequest(`${apiBasePath}/medical_records/clinical_notes`, { headers, }); @@ -95,7 +95,7 @@ export const getNote = id => { }); }; -export const getVitalsList = () => { +export const getVitalsList = async () => { return apiRequest(`${apiBasePath}/medical_records/vitals`, { headers, }); @@ -158,7 +158,7 @@ export const getAcceleratedAllergy = id => { * Get a patient's vaccines * @returns list of patient's vaccines in FHIR format */ -export const getVaccineList = () => { +export const getVaccineList = async () => { return apiRequest(`${apiBasePath}/medical_records/vaccines`, { headers, }); @@ -208,7 +208,7 @@ export const getImageRequestStatus = () => { * Get a patient's medications * @returns list of patient's medications */ -export const getMedications = () => { +export const getMedications = async () => { return apiRequest(`${apiBasePath}/prescriptions`, { headers, }); @@ -218,7 +218,7 @@ export const getMedications = () => { * Get a patient's appointments * @returns list of patient's appointments */ -export const getAppointments = () => { +export const getAppointments = async () => { const now = new Date(); const startDate = formatISO(now); const beginningOfTime = new Date(0); @@ -227,7 +227,7 @@ export const getAppointments = () => { '&statuses[]=booked&statuses[]=arrived&statuses[]=fulfilled&statuses[]=cancelled'; const params = `_include=facilities,clinics&start=${startDate}&end=${endDate}${statusParams}`; - return apiRequest(`${apiBasePath}/vaos/v2/appointments?${params}`, { + return apiRequest(`${environment.API_URL}/vaos/v2/appointments?${params}`, { headers, }); }; @@ -236,7 +236,7 @@ export const getAppointments = () => { * Get a patient's demographic info * @returns patient's demographic info */ -export const getDemographicInfo = () => { +export const getDemographicInfo = async () => { return apiRequest(`${apiBasePath}/medical_records/patient/demographic`, { headers, }); @@ -247,7 +247,7 @@ export const getDemographicInfo = () => { * Get a patient's military service info * @returns patient's military service info */ -export const getMilitaryService = () => { +export const getMilitaryService = async () => { return apiRequest(`${apiBasePath}/medical_records/military_service`, { textHeaders, }); @@ -258,7 +258,7 @@ export const getMilitaryService = () => { * Get a patient's account summary (treatment facilities) * @returns patient profile including a list of patient's treatment facilities */ -export const getPatient = () => { +export const getPatient = async () => { return apiRequest(`${apiBasePath}/medical_records/patient`, { headers, }); diff --git a/src/applications/mhv-medical-records/components/DownloadRecords/DownloadFileType.jsx b/src/applications/mhv-medical-records/components/DownloadRecords/DownloadFileType.jsx index 3690408a80fe..b1978f6517f5 100644 --- a/src/applications/mhv-medical-records/components/DownloadRecords/DownloadFileType.jsx +++ b/src/applications/mhv-medical-records/components/DownloadRecords/DownloadFileType.jsx @@ -54,6 +54,7 @@ const DownloadFileType = props => { const accountSummary = useSelector( state => state.mr.blueButton.accountSummary, ); + const failedDomains = useSelector(state => state.mr.blueButton.failedDomains); const recordFilter = useSelector(state => state.mr.downloads?.recordFilter); const dateFilter = useSelector(state => state.mr.downloads?.dateFilter); @@ -102,8 +103,29 @@ const DownloadFileType = props => { accountSummary, }; - // Check if all domains in the recordFilter are truthy - return recordFilter?.every(filter => !!dataMap[filter]); + // Map the recordFilter keys to the option list + const optionsMap = { + labTests: 'labsAndTests', + careSummaries: 'notes', + vaccines: 'vaccines', + allergies: 'allergies', + conditions: 'conditions', + vitals: 'vitals', + medications: 'medications', + upcomingAppts: 'appointments', + pastAppts: 'appointments', + demographics: 'demographics', + militaryService: 'militaryService', + accountSummary: 'patient', + }; + + // Check if all domains in the recordFilter were fetched or failed + return recordFilter?.every(filter => { + const optionDomain = optionsMap[filter]; + const isFetched = !!dataMap[filter]; + const hasFailed = failedDomains.includes(optionDomain); + return isFetched || hasFailed; + }); }, [ labsAndTests, @@ -117,6 +139,7 @@ const DownloadFileType = props => { demographics, militaryService, accountSummary, + failedDomains, recordFilter, ], ); @@ -124,7 +147,7 @@ const DownloadFileType = props => { useEffect( () => { const options = { - labs: recordFilter?.includes('labTests'), + labsAndTests: recordFilter?.includes('labTests'), notes: recordFilter?.includes('careSummaries'), vaccines: recordFilter?.includes('vaccines'), allergies: @@ -138,6 +161,7 @@ const DownloadFileType = props => { recordFilter?.includes('pastAppts'), demographics: recordFilter?.includes('demographics'), militaryService: recordFilter?.includes('militaryService'), + patient: true, }; if (!isDataFetched) { @@ -151,30 +175,40 @@ const DownloadFileType = props => { () => { if (isDataFetched) { return { - labsAndTests: recordFilter?.includes('labTests') - ? labsAndTests.filter(rec => filterByDate(rec.sortDate)) - : null, - notes: recordFilter?.includes('careSummaries') - ? notes.filter(rec => filterByDate(rec.sortByDate)) - : null, - vaccines: recordFilter?.includes('vaccines') - ? vaccines.filter(rec => filterByDate(rec.date)) - : null, + labsAndTests: + labsAndTests && recordFilter?.includes('labTests') + ? labsAndTests.filter(rec => filterByDate(rec.sortDate)) + : null, + notes: + notes && recordFilter?.includes('careSummaries') + ? notes.filter(rec => filterByDate(rec.sortByDate)) + : null, + vaccines: + vaccines && recordFilter?.includes('vaccines') + ? vaccines.filter(rec => filterByDate(rec.date)) + : null, allergies: - recordFilter?.includes('allergies') || - recordFilter?.includes('medications') + allergies && + (recordFilter?.includes('allergies') || + recordFilter?.includes('medications')) ? allergies : null, - conditions: recordFilter?.includes('conditions') ? conditions : null, - vitals: recordFilter?.includes('vitals') - ? vitals.filter(rec => filterByDate(rec.date)) - : null, - medications: recordFilter?.includes('medications') - ? medications.filter(rec => filterByDate(rec.lastFilledOn)) - : null, + conditions: + conditions && recordFilter?.includes('conditions') + ? conditions + : null, + vitals: + vitals && recordFilter?.includes('vitals') + ? vitals.filter(rec => filterByDate(rec.date)) + : null, + medications: + medications && recordFilter?.includes('medications') + ? medications.filter(rec => filterByDate(rec.lastFilledOn)) + : null, appointments: - recordFilter?.includes('upcomingAppts') || - recordFilter?.includes('pastAppts') + appointments && + (recordFilter?.includes('upcomingAppts') || + recordFilter?.includes('pastAppts')) ? appointments.filter( rec => filterByDate(rec.date) && @@ -183,12 +217,14 @@ const DownloadFileType = props => { (recordFilter.includes('pastAppts') && !rec.isUpcoming)), ) : null, - demographics: recordFilter?.includes('demographics') - ? demographics - : null, - militaryService: recordFilter?.includes('militaryService') - ? militaryService - : null, + demographics: + demographics && recordFilter?.includes('demographics') + ? demographics + : null, + militaryService: + militaryService && recordFilter?.includes('militaryService') + ? militaryService + : null, accountSummary, }; } diff --git a/src/applications/mhv-medical-records/components/DownloadRecords/MissingRecordsError.jsx b/src/applications/mhv-medical-records/components/DownloadRecords/MissingRecordsError.jsx new file mode 100644 index 000000000000..2cb114837c88 --- /dev/null +++ b/src/applications/mhv-medical-records/components/DownloadRecords/MissingRecordsError.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const MissingRecordsError = ({ recordTypes }) => { + if (!Array.isArray(recordTypes) || recordTypes.length === 0) { + return <>; + } + return ( + +

+ We can’t include certain records in your VA Blue Button report right now +

+

+ We’re sorry. There’s a problem with our system. The report you just + downloaded doesn’t include these records: +

+ +

+ Try downloading these records again later. If it still doesn’t work, + call us at ( + + ). We’re here Monday through Friday, 8:00 a.m. to 8:00 p.m. ET. +

+
+ ); +}; + +export default MissingRecordsError; + +MissingRecordsError.propTypes = { + recordTypes: PropTypes.array, +}; diff --git a/src/applications/mhv-medical-records/containers/DownloadReportPage.jsx b/src/applications/mhv-medical-records/containers/DownloadReportPage.jsx index cb644274847d..4bb360b410df 100644 --- a/src/applications/mhv-medical-records/containers/DownloadReportPage.jsx +++ b/src/applications/mhv-medical-records/containers/DownloadReportPage.jsx @@ -11,6 +11,7 @@ import { mhvUrl } from '~/platform/site-wide/mhv/utilities'; import { isAuthenticatedWithSSOe } from '~/platform/user/authentication/selectors'; import NeedHelpSection from '../components/DownloadRecords/NeedHelpSection'; import ExternalLink from '../components/shared/ExternalLink'; +import MissingRecordsError from '../components/DownloadRecords/MissingRecordsError'; import { getSelfEnteredAllergies, getSelfEnteredVitals, @@ -76,10 +77,36 @@ const DownloadReportPage = ({ runningUnitTest }) => { const vaccines = useSelector(state => state.mr.selfEntered.vaccines); const vitals = useSelector(state => state.mr.selfEntered.vitals); + const failedDomains = useSelector(state => state.mr.blueButton.failedDomains); + const [selfEnteredInfoRequested, setSelfEnteredInfoRequested] = useState( false, ); + /** Map from the list of failed domains to UI display names */ + const domainDisplayMap = { + labsAndTests: 'Lab and test results', + notes: 'Care summaries and notes', + vaccines: 'Vaccines', + allergies: 'Allergies and reactions', + conditions: 'Health conditions', + vitals: 'Vitals', + radiology: 'Radiology results', + medications: 'Medications', + appointments: 'VA appointments', + demographics: 'VA demographics records', + militaryService: 'DOD military service', + patient: 'Account summary', + }; + + const getFailedDomainList = (failed, displayMap) => { + const modFailed = [...failed]; + if (modFailed.includes('allergies') && !modFailed.includes('medications')) { + modFailed.push('medications'); + } + return modFailed.map(domain => displayMap[domain]); + }; + useEffect( () => { return () => { @@ -252,15 +279,23 @@ const DownloadReportPage = ({ runningUnitTest }) => { Download your VA medical records as a single report (called your VA Blue Button® report). Or find other reports to download.

-
+

Records in these reports last updated at 1:47 p.m. [time zone] on June 23, 2024

+ {successfulDownload === true && ( - + <> + + + + )} +

Download your VA Blue Button report

First, select the types of records you want in your report. Then diff --git a/src/applications/mhv-medical-records/reducers/blueButton.js b/src/applications/mhv-medical-records/reducers/blueButton.js index 980132d7a1db..81fe79940229 100644 --- a/src/applications/mhv-medical-records/reducers/blueButton.js +++ b/src/applications/mhv-medical-records/reducers/blueButton.js @@ -7,31 +7,23 @@ import { medicationTypes, NA, NONE_RECORDED, UNKNOWN } from '../util/constants'; import { dateFormat } from '../util/helpers'; const initialState = { - /** - * The list of medications returned from the api - * @type {Array} - */ + /** The list of medications returned from the api @type {Array} */ medicationsList: undefined, - /** - * The list of appointments returned from the api - * @type {Array} - */ + + /** The list of appointments returned from the api @type {Array} */ appointmentsList: undefined, - /** - * The demographic info returned from the api - * @type {Array} - */ + + /** The demographic info returned from the api @type {Array} */ demographics: undefined, - /** - * The military service info returned from the api - * @type {Array} - */ + + /** The military service info returned from the api @type {Array} */ militaryService: undefined, - /** - * The account summary info returned from the api - * @type {Array} - */ + + /** The account summary info returned from the api @type {Array} */ accountSummary: undefined, + + /** A list of domains which failed during fetch @type {Array} */ + failedDomains: [], }; /** @@ -157,7 +149,7 @@ export const convertDemographics = info => { return { id: info.id, - facility: info.facilityInfo.name || NONE_RECORDED, + facility: info.facilityInfo?.name || NONE_RECORDED, firstName: info.firstName, middleName: info.middleName || NONE_RECORDED, lastName: info.lastName || NONE_RECORDED, @@ -279,9 +271,9 @@ export const convertAccountSummary = data => { // Map facilities const mappedFacilities = facilities.map(facility => ({ - facilityName: facility.facilityInfo.name, - stationNumber: facility.facilityInfo.stationNumber, - type: facility.facilityInfo.treatment ? 'Treatment' : 'VAMC', + facilityName: facility.facilityInfo?.name || 'Unknown facility', + stationNumber: facility.facilityInfo?.stationNumber || 'Unknown ID', + type: facility.facilityInfo?.treatment ? 'Treatment' : 'VAMC', })); // Extract user profile details @@ -289,8 +281,7 @@ export const convertAccountSummary = data => { const authenticatingFacility = ipa?.authenticatingFacilityId && facilities.find( - facility => - facility.facilityInfo.stationNumber === ipa.authenticatingFacilityId, + facility => facility.facilityInfo?.id === ipa.authenticatingFacilityId, ); const authenticationInfo = ipa @@ -303,7 +294,7 @@ export const convertAccountSummary = data => { authenticationFacilityName: authenticatingFacility?.facilityInfo?.name || 'Unknown facility', authenticationFacilityID: - authenticatingFacility?.facilityInfo?.stationNumber || 'Unknown ID', + authenticatingFacility?.facilityInfo?.id || 'Unknown ID', } : {}; @@ -314,7 +305,6 @@ export const convertAccountSummary = data => { }; export const blueButtonReducer = (state = initialState, action) => { - // eslint-disable-next-line sonarjs/no-small-switch switch (action.type) { case Actions.BlueButtonReport.GET: { const updates = {}; @@ -354,6 +344,22 @@ export const blueButtonReducer = (state = initialState, action) => { ...updates, }; } + case Actions.BlueButtonReport.ADD_FAILED: { + const failedDomain = action.payload; + + return { + ...state, + failedDomains: state.failedDomains.includes(failedDomain) + ? state.failedDomains + : [...state.failedDomains, failedDomain], + }; + } + case Actions.BlueButtonReport.CLEAR_FAILED: { + return { + ...state, + failedDomains: [], + }; + } default: return state; } diff --git a/src/applications/mhv-medical-records/tests/actions/blueButtonReport.spec.js b/src/applications/mhv-medical-records/tests/actions/blueButtonReport.spec.js index 5b3a295d278a..a50b249cc608 100644 --- a/src/applications/mhv-medical-records/tests/actions/blueButtonReport.spec.js +++ b/src/applications/mhv-medical-records/tests/actions/blueButtonReport.spec.js @@ -28,4 +28,19 @@ describe('getBlueButtonReportData', () => { expect(dispatch.notCalled).to.be.true; }); }); + + it('should dispatch an error for a failed API call', () => { + const mockData = allergies; + mockApiRequest(mockData, false); // Unresolved promise + const dispatch = sinon.spy(); + return getBlueButtonReportData({ allergies: true })(dispatch).then(() => { + // Verify that dispatch was called only once + expect(dispatch.calledOnce).to.be.true; + + // Check the first and only dispatch action type + expect(dispatch.firstCall.args[0].type).to.equal( + Actions.BlueButtonReport.ADD_FAILED, + ); + }); + }); }); diff --git a/src/applications/mhv-medical-records/tests/reducers/blueButton.unit.spec.js b/src/applications/mhv-medical-records/tests/reducers/blueButton.unit.spec.js index 1290ab97e192..b00a3404adf7 100644 --- a/src/applications/mhv-medical-records/tests/reducers/blueButton.unit.spec.js +++ b/src/applications/mhv-medical-records/tests/reducers/blueButton.unit.spec.js @@ -277,8 +277,9 @@ describe('convertAccountSummary', () => { facilities: [ { facilityInfo: { + id: '123', name: 'VA Medical Center', - stationNumber: '123', + stationNumber: 'TEST', treatment: true, }, }, @@ -330,6 +331,7 @@ describe('blueButtonReducer', () => { demographics: undefined, militaryService: undefined, accountSummary: undefined, + failedDomains: [], }; it('should return the initial state when passed an undefined state', () => { @@ -409,6 +411,7 @@ describe('blueButtonReducer', () => { 'demographics', 'militaryService', 'accountSummary', + 'failedDomains', ]); expect(newState.medicationsList).to.be.an('array'); diff --git a/src/applications/mhv-medical-records/util/actionTypes.js b/src/applications/mhv-medical-records/util/actionTypes.js index 86a7508068ff..a38042af9ca1 100644 --- a/src/applications/mhv-medical-records/util/actionTypes.js +++ b/src/applications/mhv-medical-records/util/actionTypes.js @@ -84,6 +84,8 @@ export const Actions = { }, BlueButtonReport: { GET: 'MR_BLUE_BUTTON_GET_DATA', + ADD_FAILED: 'MR_BLUE_BUTTON_ADD_FAILED', + CLEAR_FAILED: 'MR_BLUE_BUTTON_CLEAR_FAILED', }, PageTracker: { SET_PAGE_TRACKER: 'MR_SET_PAGE_TRACKER',