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’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 can’t include certain records in your VA Blue Button report right now
+
+
+ {recordTypes.map(recordType => (
+
+
Records in these reports last updated at 1:47 p.m. [time zone] on June 23, 2024
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',