diff --git a/.gitignore b/.gitignore index b0bb2fa64c..a7e7fcad71 100644 --- a/.gitignore +++ b/.gitignore @@ -53,4 +53,7 @@ locales/*/*_old.json *.code-workspace .pi .pi-lens -.apo +.claude + +# ApoVault — ignore entire vault locally +.apo/ diff --git a/app/components/clinic/PatientForm/PatientForm.js b/app/components/clinic/PatientForm/PatientForm.js index 55ca397d3f..6c0b07dce8 100644 --- a/app/components/clinic/PatientForm/PatientForm.js +++ b/app/components/clinic/PatientForm/PatientForm.js @@ -38,7 +38,6 @@ export function getFormValues(source, clinicPatientTags, clinicSites) { fullName: source?.fullName || '', mrn: source?.mrn || '', tags: reject(source?.tags || [], tagId => !clinicPatientTags?.[tagId]), - dataSources: source?.dataSources || [], sites: source?.sites?.filter(site => !!clinicSites[site.id]) || [], diagnosisType: source?.diagnosisType || '', glycemicRanges: source?.glycemicRanges || DEFAULT_GLYCEMIC_RANGES, diff --git a/app/components/datasources/DataConnections.js b/app/components/datasources/DataConnections.js index 709ef5f43c..2c40a5b118 100644 --- a/app/components/datasources/DataConnections.js +++ b/app/components/datasources/DataConnections.js @@ -7,7 +7,6 @@ import CheckRoundedIcon from '@material-ui/icons/CheckRounded'; import moment from 'moment-timezone'; import defaults from 'lodash/defaults'; import filter from 'lodash/filter'; -import find from 'lodash/find'; import get from 'lodash/get'; import includes from 'lodash/includes'; import intersection from 'lodash/intersection'; @@ -15,6 +14,7 @@ import isFunction from 'lodash/isFunction'; import keys from 'lodash/keys'; import map from 'lodash/map'; import max from 'lodash/max'; +import maxBy from 'lodash/maxBy'; import noop from 'lodash/noop'; import orderBy from 'lodash/orderBy'; import reduce from 'lodash/reduce'; @@ -107,21 +107,9 @@ export function getProviderHandlers(patient, selectedClinicId, provider) { const { id, restrictedTokenCreate, dataSourceFilter } = provider; const providerName = dataSourceFilter?.providerName; - // Clinician-initiated send and resend invite handlers will potentially need to gather an email - // address and set the initial data source pending status on the patient if these do not exist. + // Clinician-initiated send and resend invite handlers may need to gather a patient email address + // before the connection request can be sent. const emailRequired = !!(selectedClinicId && !patient?.email && patient?.permissions?.custodian); - const hasProviderDataSource = !!find(patient?.dataSources, { providerName }); - - let patientUpdates; - - if (!hasProviderDataSource) { - patientUpdates = { - dataSources: [ - ...patient?.dataSources || [], - { providerName, state: 'pending' }, - ], - }; - } return { connect: { @@ -156,7 +144,6 @@ export function getProviderHandlers(patient, selectedClinicId, provider) { action: actions.async.sendPatientDataProviderConnectRequest, args: [api, selectedClinicId, patient?.id, providerName], emailRequired, - patientUpdates, }, resendInvite: { buttonText: t('Resend Invite'), @@ -164,7 +151,6 @@ export function getProviderHandlers(patient, selectedClinicId, provider) { action: actions.async.sendPatientDataProviderConnectRequest, args: [api, selectedClinicId, patient?.id, providerName], emailRequired, - patientUpdates, }, } }; @@ -178,11 +164,9 @@ export const getCurrentDataSourceForProvider = (patient, providerName) => { // Define state priority order const statePriority = { - pending: 1, - connected: 2, - error: 3, - pendingReconnect: 4, - disconnected: 5, + connected: 1, + error: 2, + disconnected: 3, }; // Sort by state priority, then by lastImportTime (descending) for disconnected states @@ -194,6 +178,60 @@ export const getCurrentDataSourceForProvider = (patient, providerName) => { return sortedDataSources[0]; }; +/** + * Return the meaningful connection request for a provider. A provider can carry more than one + * request in edge cases; newer requests supersede older ones, so only the most recently created + * request is meaningful. Selecting by createdTime keeps this correct independent of array order. + * + * @param {Object} patient + * @param {String} providerName + */ +export const getCurrentConnectionRequestForProvider = (patient, providerName) => + maxBy(patient?.connectionRequests?.[providerName], request => { + const createdTime = moment.utc(request.createdTime, moment.ISO_8601, true); + // Treat a missing/invalid createdTime as the oldest so it never wins selection. + return createdTime.isValid() ? createdTime.valueOf() : -Infinity; + }); + +/** + * Derive a single connectState identifier for a provider by joining + * patient.dataSources with patient.connectionRequests[providerName]: + * + * - pending = non-expired connectionRequest, no dataSource + * - pendingReconnect = non-expired connectionRequest + non-connected dataSource + * - pendingExpired = expired connectionRequest + no connected dataSource + * - connected = dataSource state === 'connected' + * - disconnected = dataSource state === 'disconnected' + * - error = dataSource state === 'error' + * - unknown = dataSource state is none of the above + * - noPendingConnections = no dataSource and no connectionRequest + * + * @param {Object} patient + * @param {String} providerName + * @param {String} [now] - ISO 8601 timestamp; defaults to moment.utc().toISOString() + */ +export const resolveConnectState = (patient, providerName, now = moment.utc().toISOString()) => { + const dataSource = getCurrentDataSourceForProvider(patient, providerName); + const connectionRequest = getCurrentConnectionRequestForProvider(patient, providerName); + const requestExpired = !!connectionRequest?.expirationTime + && moment.utc(connectionRequest.expirationTime).isBefore(now); + + if (connectionRequest && !requestExpired) { + if (!dataSource) return 'pending'; + if (dataSource.state !== 'connected') return 'pendingReconnect'; + } + + if (connectionRequest && requestExpired && (!dataSource || dataSource.state !== 'connected')) { + return 'pendingExpired'; + } + + if (!dataSource) return 'noPendingConnections'; + + return includes(['connected', 'disconnected', 'error'], dataSource.state) + ? dataSource.state + : 'unknown'; +}; + export const getConnectStateUI = (patient, isLoggedInUser, providerName) => { const dataSource = getCurrentDataSourceForProvider(patient, providerName); @@ -204,7 +242,7 @@ export const getConnectStateUI = (patient, isLoggedInUser, providerName) => { dataSource?.modifiedTime, ]) : max([ dataSource?.modifiedTime, - patient?.connectionRequests?.[providerName]?.[0]?.createdTime + getCurrentConnectionRequestForProvider(patient, providerName)?.createdTime ]); let timeAgo; @@ -309,26 +347,11 @@ export const getConnectStateUI = (patient, isLoggedInUser, providerName) => { export const getDataConnectionProps = (patient, isLoggedInUser, selectedClinicId, setActiveHandler) => reduce(availableProviders, (result, providerName) => { result[providerName] = {}; - let connectState; - - const dataSource = getCurrentDataSourceForProvider(patient, providerName); const connectStateUI = getConnectStateUI(patient, isLoggedInUser, providerName); - const inviteExpired = dataSource?.expirationTime < moment.utc().toISOString(); - - if (dataSource?.state) { - connectState = includes(keys(connectStateUI), dataSource.state) - ? dataSource.state - : 'unknown'; - - if (includes(['pending', 'pendingReconnect'], connectState)) { - if (inviteExpired) { - connectState = 'pendingExpired'; - } else if (connectStateUI[connectState].inviteJustSent) { - connectState = 'inviteJustSent'; - } - } - } else { - connectState = 'noPendingConnections'; + let connectState = resolveConnectState(patient, providerName); + + if (includes(['pending', 'pendingReconnect'], connectState) && connectStateUI[connectState]?.inviteJustSent) { + connectState = 'inviteJustSent'; } const { color, icon, message, text, handler } = connectStateUI[connectState]; @@ -341,13 +364,12 @@ export const getDataConnectionProps = (patient, isLoggedInUser, selectedClinicId buttonText, buttonStyle, emailRequired, - patientUpdates, } = getProviderHandlers(patient, selectedClinicId, providers[providerName])[handler] || {}; if (action) { result[providerName].buttonDisabled = buttonDisabled; result[providerName].buttonIcon = buttonIcon; - result[providerName].buttonHandler = () => setActiveHandler({ action, args, emailRequired, patientUpdates, providerName, connectState, handler }); + result[providerName].buttonHandler = () => setActiveHandler({ action, args, emailRequired, providerName, connectState, handler }); result[providerName].buttonText = buttonText; result[providerName].buttonStyle = buttonStyle; } @@ -384,7 +406,6 @@ export const DataConnections = (props) => { const [showPatientEmailModal, setShowPatientEmailModal] = useState(false); const [patientEmailFormContext, setPatientEmailFormContext] = useState(); const [processingEmailUpdate, setProcessingEmailUpdate] = useState(false); - const [patientUpdates, setPatientUpdates] = useState({}); const [activeHandler, setActiveHandler] = useState(null); const dataConnectionProps = getDataConnectionProps(patient, isLoggedInUser, selectedClinicId, setActiveHandler); const activeProviders = getActiveProviders(); @@ -434,7 +455,6 @@ export const DataConnections = (props) => { setShowPatientEmailModal(false); setProcessingEmailUpdate(false); - setPatientUpdates({}); setActiveHandler(null); } } @@ -470,18 +490,12 @@ export const DataConnections = (props) => { fetchPatientDetails(); setShowPatientEmailModal(false); setProcessingEmailUpdate(false); - setPatientUpdates({}); - - if (activeHandler?.action) { - if (activeHandler?.emailRequired) { - // Immediately after adding a new patient email address. There will be a small amount - // of time where the backend services may not be able to find the patient, so we wait - // a second before requesting that a connection request email be sent. - setTimeout(() => dispatch(activeHandler.action(...activeHandler.args)), 1000); - } else { - // If we haven't just added an email to a patient, we can fire this right away. - dispatch(activeHandler.action(...activeHandler.args)); - } + + if (activeHandler?.action && activeHandler?.emailRequired) { + // Immediately after adding a new patient email address. There will be a small amount + // of time where the backend services may not be able to find the patient, so we wait + // a second before requesting that a connection request email be sent. + setTimeout(() => dispatch(activeHandler.action(...activeHandler.args)), 1000); } }, [ activeHandler, @@ -550,17 +564,12 @@ export const DataConnections = (props) => { setActiveHandler({ ...activeHandler, inProgress: true }); if (activeHandler.emailRequired) { - // Store any patient updates in state. We will collect the email address, and then add it - // to the updates obect before applying them. - setPatientUpdates(activeHandler.patientUpdates || {}); + // Custodial patient is missing an email; gather it via PatientEmailModal, then fire the + // connection request from handleUpdatePatientComplete. handleAddPatientEmailOpen(); - } else if (patient && activeHandler.patientUpdates) { - // We have updates to apply before we can fire the data connection action. - dispatch(actions.async.updateClinicPatient(api, selectedClinicId, patient.id, { ...patient, ...activeHandler.patientUpdates })); } else if (activeHandler.handler === 'resendInvite') { handleResendDataSourceConnectEmailOpen(); } else { - // No need to update patient object prior to firing data connection action. Fire away. dispatch(activeHandler.action(...activeHandler.args)); } } @@ -569,8 +578,6 @@ export const DataConnections = (props) => { dispatch, handleAddPatientEmailOpen, handleResendDataSourceConnectEmailOpen, - patient, - selectedClinicId, ]); useEffect(() => { @@ -632,7 +639,7 @@ export const DataConnections = (props) => { onClose={handleAddPatientEmailClose} onFormChange={handleAddPatientEmailFormChange} onSubmit={handleAddPatientEmailConfirm} - patient={{ ...patient, ...patientUpdates }} + patient={patient} processing={processingEmailUpdate} trackMetric={trackMetric} />} @@ -659,10 +666,9 @@ export const DataConnections = (props) => { }; const clinicPatientDataSourceShape = { - expirationTime: PropTypes.string, modifiedTime: PropTypes.string, providerName: PropTypes.string.isRequired, - state: PropTypes.oneOf(['connected', 'disconnected', 'error', 'pending', 'pendingReconnect']).isRequired, + state: PropTypes.oneOf(['connected', 'disconnected', 'error']).isRequired, }; const userDataSourceShape = { @@ -671,13 +677,20 @@ const userDataSourceShape = { latestDataTime: PropTypes.string, modifiedTime: PropTypes.string, providerName: PropTypes.string.isRequired, - state: PropTypes.oneOf(['connected', 'disconnected', 'error', 'pending', 'pendingReconnect']).isRequired, + state: PropTypes.oneOf(['connected', 'disconnected', 'error']).isRequired, +}; + +const connectionRequestShape = { + providerName: PropTypes.string.isRequired, + createdTime: PropTypes.string.isRequired, + expirationTime: PropTypes.string, }; DataConnections.propTypes = { ...BoxProps, patient: PropTypes.shape({ - dataSources: PropTypes.oneOf([PropTypes.shape(clinicPatientDataSourceShape), PropTypes.shape(userDataSourceShape)]) + dataSources: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.shape(clinicPatientDataSourceShape), PropTypes.shape(userDataSourceShape)])), + connectionRequests: PropTypes.objectOf(PropTypes.arrayOf(PropTypes.shape(connectionRequestShape))), }), shownProviders: PropTypes.arrayOf(PropTypes.oneOf(availableProviders)), trackMetric: PropTypes.func.isRequired, diff --git a/app/core/clinicUtils.js b/app/core/clinicUtils.js index ee925e273b..e12773c4e3 100644 --- a/app/core/clinicUtils.js +++ b/app/core/clinicUtils.js @@ -435,13 +435,6 @@ export const patientSchema = config => { .required(t('Patient\'s birthday is required')), mrn: mrnSchema, email: yup.string().email(t('Please enter a valid email address')), - connectDexcom: yup.boolean(), - dataSources: yup.array().of( - yup.object().shape({ - providerName: yup.string(), - state: yup.string().oneOf(['pending', 'pendingReconnect', 'connected', 'error', 'disconnected']), - }), - ), tags: yup.array().of( yup.string() ), diff --git a/app/pages/dashboard/TideDashboard.js b/app/pages/dashboard/TideDashboard.js index 924dd66d60..fe0b1786f3 100644 --- a/app/pages/dashboard/TideDashboard.js +++ b/app/pages/dashboard/TideDashboard.js @@ -53,6 +53,7 @@ import PatientForm from '../../components/clinic/PatientForm'; import TideDashboardConfigForm, { validateTideConfig } from '../../components/clinic/TideDashboardConfigForm'; import BgSummaryCell from '../../components/clinic/BgSummaryCell'; import DataConnectionsModal from '../../components/datasources/DataConnectionsModal'; +import { resolveConnectState } from '../../components/datasources/DataConnections'; import Popover from '../../components/elements/Popover'; import PopoverMenu from '../../components/elements/PopoverMenu'; import RadioGroup from '../../components/elements/RadioGroup'; @@ -564,19 +565,7 @@ const TideDashboardSection = React.memo(props => { ]); const renderDexcomConnectionStatus = useCallback(({ patient }) => { - const dexcomDataSource = find(patient?.dataSources, { providerName: 'dexcom' }); - const dexcomAuthInviteExpired = dexcomDataSource?.expirationTime < moment.utc().toISOString(); - let dexcomConnectState; - - if (dexcomDataSource) { - dexcomConnectState = includes(keys(dexcomConnectStateUI), dexcomDataSource?.state) - ? dexcomDataSource.state - : 'unknown'; - - if (includes(['pending', 'pendingReconnect'], dexcomConnectState) && dexcomAuthInviteExpired) dexcomConnectState = 'pendingExpired'; - } else { - dexcomConnectState = 'noPendingConnections'; - } + const dexcomConnectState = resolveConnectState(patient, 'dexcom'); const showViewButton = includes([ 'disconnected', diff --git a/package.json b/package.json index edef3f7267..13a62c2c32 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "node": "24.15.0" }, "packageManager": "yarn@3.6.4", - "version": "1.97.0-web-3359.3", + "version": "1.97.0-web-4595-pending-data-source-migration.1", "private": true, "scripts": { "test": "TZ=UTC NODE_ENV=test yarn jest --verbose", diff --git a/stories/DataConnection.stories.js b/stories/DataConnection.stories.js index 27853fab02..247e0d6b40 100644 --- a/stories/DataConnection.stories.js +++ b/stories/DataConnection.stories.js @@ -2,6 +2,7 @@ import React from 'react'; import { action } from '@storybook/addon-actions'; import { ThemeProvider } from '@emotion/react'; import moment from 'moment-timezone'; +import includes from 'lodash/includes'; import map from 'lodash/map'; import noop from 'lodash/noop'; @@ -27,22 +28,42 @@ export default { decorators: [withTheme], }; -const patientWithState = (isClinicContext, state, opts = {}) => ({ - id: 'patient123', - dataSources: state ? map(availableProviders, providerName => ({ - providerName, - state, - createdTime: opts.createdTime, - modifiedTime: opts.modifiedTime, - expirationTime: opts.expirationTime, - lastImportTime: opts.lastImportTime, - latestDataTime: opts.latestDataTime, - })) : undefined, - connectionRequests: isClinicContext && opts.createdTime ? reduce(availableProviders, (res, providerName) => { - res[providerName] = [{ providerName, createdTime: opts.createdTime }]; - return res; - }, {}) : undefined, -}); +// The invite lifecycle lives on patient.connectionRequests; established +// connections live on patient.dataSources. Stories using a `pending` / `pendingReconnect` +// / `pendingExpired` state pass through the connectionRequests path. `pendingReconnect` +// additionally seeds a non-connected dataSource whose modifiedTime predates the request. +const requestDrivenStates = ['pending', 'pendingReconnect', 'pendingExpired']; + +const patientWithState = (isClinicContext, state, opts = {}) => { + const hasDataSource = !!state && !includes(['pending', 'pendingExpired'], state); + const dataSourceState = state === 'pendingReconnect' ? 'disconnected' : state; + const dataSourceModifiedTime = state === 'pendingReconnect' + ? moment.utc(opts.createdTime).subtract(1, 'day').toISOString() + : opts.modifiedTime; + const hasConnectionRequest = isClinicContext && includes(requestDrivenStates, state); + const requestExpirationTime = opts.expirationTime + || (opts.createdTime ? moment.utc(opts.createdTime).add(30, 'days').toISOString() : undefined); + + return { + id: 'patient123', + dataSources: hasDataSource ? map(availableProviders, providerName => ({ + providerName, + state: dataSourceState, + createdTime: opts.createdTime, + modifiedTime: dataSourceModifiedTime, + lastImportTime: opts.lastImportTime, + latestDataTime: opts.latestDataTime, + })) : undefined, + connectionRequests: hasConnectionRequest ? reduce(availableProviders, (res, providerName) => { + res[providerName] = [{ + providerName, + createdTime: opts.createdTime, + expirationTime: requestExpirationTime, + }]; + return res; + }, {}) : undefined, + }; +}; const getDateInPast = (amount, unit) => moment.utc().subtract(amount, unit).toISOString(); diff --git a/test/fixtures/mockTideDashboardPatients.json b/test/fixtures/mockTideDashboardPatients.json index acc8fd2eef..aa7687e739 100644 --- a/test/fixtures/mockTideDashboardPatients.json +++ b/test/fixtures/mockTideDashboardPatients.json @@ -657,9 +657,13 @@ "646f7ed208e23bc18d91caa3", "646f7ed708e23bc18d91caa4" ], - "dataSources": [ - { "providerName": "dexcom", "state": "pending", "expirationTime": "2024-11-17T15:17:20.159+00:00" } - ] + "connectionRequests": { + "dexcom": [{ + "providerName": "dexcom", + "createdTime": "2024-10-18T15:17:20.159+00:00", + "expirationTime": "2024-11-17T15:17:20.159+00:00" + }] + } } }, { @@ -684,9 +688,13 @@ "6455353c299ee2ccddbdbe94", "646f7f0408e23bc18d91caa7" ], - "dataSources": [ - { "providerName": "dexcom", "state": "pending" } - ] + "connectionRequests": { + "dexcom": [{ + "providerName": "dexcom", + "createdTime": "2099-01-01T00:00:00.000+00:00", + "expirationTime": "2099-12-31T00:00:00.000+00:00" + }] + } } } ] diff --git a/test/unit/app/core/clinicUtils.test.js b/test/unit/app/core/clinicUtils.test.js index 9b4fd83e1c..7a599a82eb 100644 --- a/test/unit/app/core/clinicUtils.test.js +++ b/test/unit/app/core/clinicUtils.test.js @@ -756,8 +756,6 @@ describe('clinicUtils', function() { 'mrn', 'sites', 'tags', - 'connectDexcom', - 'dataSources', 'glycemicRanges', 'diagnosisType', ]); diff --git a/test/unit/components/chart/settings.test.js b/test/unit/components/chart/settings.test.js index 2cf546f6b1..09b09ad85e 100644 --- a/test/unit/components/chart/settings.test.js +++ b/test/unit/components/chart/settings.test.js @@ -1443,7 +1443,7 @@ describe('Settings', () => { isUserPatient: false, clinicPatient: { ...clinicPatient, - dataSources: _.map(availableProviders, providerName => ({ providerName, state: 'pending' })), + dataSources: _.map(availableProviders, providerName => ({ providerName, state: 'connected' })), }, }; @@ -1545,7 +1545,7 @@ describe('Settings', () => { const state = { blip: { ...defaultState.blip, - dataSources: _.map(availableProviders, providerName => ({ providerName, state: 'pending' })), + dataSources: _.map(availableProviders, providerName => ({ providerName, state: 'connected' })), } }; diff --git a/test/unit/components/datasources/DataConnections.test.js b/test/unit/components/datasources/DataConnections.test.js index 290842bc13..d4c770f9e0 100644 --- a/test/unit/components/datasources/DataConnections.test.js +++ b/test/unit/components/datasources/DataConnections.test.js @@ -6,6 +6,7 @@ import { Provider } from 'react-redux'; import { thunk } from 'redux-thunk'; import CheckRoundedIcon from '@material-ui/icons/CheckRounded'; import { ToastProvider } from '../../../../app/providers/ToastProvider'; +import includes from 'lodash/includes'; import map from 'lodash/map'; import reduce from 'lodash/reduce'; import coreApi from '../../../../app/core/api'; @@ -22,7 +23,8 @@ import DataConnections, { getProviderHandlers, getCurrentDataSourceForProvider, getConnectStateUI, - getDataConnectionProps + getDataConnectionProps, + resolveConnectState, } from '../../../../app/components/datasources/DataConnections'; /* global chai */ @@ -141,7 +143,6 @@ describe('getProviderHandlers', () => { action: appActions.async.sendPatientDataProviderConnectRequest, args: [coreApi, 'clinic123', 'patient123', 'provider123'], emailRequired: false, - patientUpdates: undefined, }, resendInvite: { buttonText: 'Resend Invite', @@ -149,7 +150,6 @@ describe('getProviderHandlers', () => { action: appActions.async.sendPatientDataProviderConnectRequest, args: [coreApi, 'clinic123', 'patient123', 'provider123'], emailRequired: false, - patientUpdates: undefined, }, }); }); @@ -163,15 +163,96 @@ describe('getProviderHandlers', () => { expect(handlers.sendInvite.emailRequired).to.be.true; expect(handlers.resendInvite.emailRequired).to.be.true; }); +}); - it('should set patientUpdates to include pending data source for send and resend invite actions if patient does not have a data source for the provider', () => { - const patient = { id: 'patient123', email: 'patient@123.com', permissions: { custodian: {} }, dataSources: [ { providerName: 'otherProvider' }] }; - const selectedClinicId = 'clinic123'; - const provider = { id: 'oauth/provider123', dataSourceFilter: { providerName: 'provider123' }, restrictedTokenCreate: { paths: ['/v1/oauth/provider123'] }}; +describe('resolveConnectState', () => { + const NOW = '2026-05-25T12:00:00Z'; + const past = (days) => moment.utc(NOW).subtract(days, 'days').toISOString(); + const future = (days) => moment.utc(NOW).add(days, 'days').toISOString(); - const handlers = getProviderHandlers(patient, selectedClinicId, provider); - expect(handlers.sendInvite.patientUpdates).to.eql({ dataSources: [ { providerName: 'otherProvider' }, { providerName: 'provider123', state: 'pending' } ]}); - expect(handlers.resendInvite.patientUpdates).to.eql({ dataSources: [ { providerName: 'otherProvider' }, { providerName: 'provider123', state: 'pending' } ]}); + it('returns noPendingConnections when no dataSource and no connectionRequest', () => { + const patient = { dataSources: [], connectionRequests: {} }; + expect(resolveConnectState(patient, 'dexcom', NOW)).to.equal('noPendingConnections'); + }); + + it('returns pending when a non-expired connectionRequest exists and no dataSource', () => { + const patient = { + connectionRequests: { dexcom: [{ providerName: 'dexcom', createdTime: past(2), expirationTime: future(28) }] }, + }; + expect(resolveConnectState(patient, 'dexcom', NOW)).to.equal('pending'); + }); + + it('returns pendingExpired when a connectionRequest exists and is expired and no connected dataSource', () => { + const patient = { + connectionRequests: { dexcom: [{ providerName: 'dexcom', createdTime: past(31), expirationTime: past(1) }] }, + }; + expect(resolveConnectState(patient, 'dexcom', NOW)).to.equal('pendingExpired'); + }); + + it('returns pendingReconnect when a non-expired connectionRequest exists alongside a non-connected dataSource', () => { + const patient = { + dataSources: [{ providerName: 'dexcom', state: 'disconnected', modifiedTime: past(10) }], + connectionRequests: { dexcom: [{ providerName: 'dexcom', createdTime: past(2), expirationTime: future(28) }] }, + }; + expect(resolveConnectState(patient, 'dexcom', NOW)).to.equal('pendingReconnect'); + }); + + it('returns connected when dataSource is connected, even with a recent connectionRequest', () => { + const patient = { + dataSources: [{ providerName: 'dexcom', state: 'connected', modifiedTime: past(1) }], + connectionRequests: { dexcom: [{ providerName: 'dexcom', createdTime: past(2), expirationTime: future(28) }] }, + }; + expect(resolveConnectState(patient, 'dexcom', NOW)).to.equal('connected'); + }); + + it('does not surface pendingExpired when dataSource is connected', () => { + const patient = { + dataSources: [{ providerName: 'dexcom', state: 'connected', modifiedTime: past(1) }], + connectionRequests: { dexcom: [{ providerName: 'dexcom', createdTime: past(60), expirationTime: past(30) }] }, + }; + expect(resolveConnectState(patient, 'dexcom', NOW)).to.equal('connected'); + }); + + it('returns disconnected / error when no connectionRequest is present', () => { + expect(resolveConnectState({ + dataSources: [{ providerName: 'dexcom', state: 'disconnected', modifiedTime: past(1) }], + }, 'dexcom', NOW)).to.equal('disconnected'); + + expect(resolveConnectState({ + dataSources: [{ providerName: 'dexcom', state: 'error', modifiedTime: past(1) }], + }, 'dexcom', NOW)).to.equal('error'); + }); + + it('treats a missing expirationTime as "not expired"', () => { + const patient = { + connectionRequests: { dexcom: [{ providerName: 'dexcom', createdTime: past(60) }] }, + }; + expect(resolveConnectState(patient, 'dexcom', NOW)).to.equal('pending'); + }); + + it('uses the newest request when multiple exist, regardless of array order', () => { + // An older expired request and a newer non-expired request. The newest supersedes, + // so the state is driven by the non-expired request — even when it is not first. + const olderExpired = { providerName: 'dexcom', createdTime: past(60), expirationTime: past(30) }; + const newerValid = { providerName: 'dexcom', createdTime: past(2), expirationTime: future(28) }; + + expect(resolveConnectState({ + connectionRequests: { dexcom: [olderExpired, newerValid] }, + }, 'dexcom', NOW)).to.equal('pending'); + + // Same set, reversed order — selection must not depend on index [0]. + expect(resolveConnectState({ + connectionRequests: { dexcom: [newerValid, olderExpired] }, + }, 'dexcom', NOW)).to.equal('pending'); + }); + + it('reports pendingExpired when the newest request is the expired one', () => { + const olderValidByExpiry = { providerName: 'dexcom', createdTime: past(60), expirationTime: future(5) }; + const newerExpired = { providerName: 'dexcom', createdTime: past(2), expirationTime: past(1) }; + + expect(resolveConnectState({ + connectionRequests: { dexcom: [olderValidByExpiry, newerExpired] }, + }, 'dexcom', NOW)).to.equal('pendingExpired'); }); }); @@ -192,25 +273,10 @@ describe('getCurrentDataSourceForProvider', () => { expect(result).to.be.undefined; }); - it('should return pending data source as highest priority', () => { + it('should return connected data source as highest priority', () => { const patient = { dataSources: [ { providerName: 'dexcom', state: 'disconnected', lastImportTime: '2024-02-01T00:00:00Z' }, - { providerName: 'dexcom', state: 'pendingReconnect' }, - { providerName: 'dexcom', state: 'error' }, - { providerName: 'dexcom', state: 'connected' }, - { providerName: 'dexcom', state: 'pending' }, - ], - }; - const result = getCurrentDataSourceForProvider(patient, 'dexcom'); - expect(result.state).to.equal('pending'); - }); - - it('should return connected data source when no pending exists', () => { - const patient = { - dataSources: [ - { providerName: 'dexcom', state: 'disconnected', lastImportTime: '2024-02-01T00:00:00Z' }, - { providerName: 'dexcom', state: 'pendingReconnect' }, { providerName: 'dexcom', state: 'error' }, { providerName: 'dexcom', state: 'connected' }, ], @@ -219,11 +285,10 @@ describe('getCurrentDataSourceForProvider', () => { expect(result.state).to.equal('connected'); }); - it('should return error data source when no pending or connected exists', () => { + it('should return error data source when no connected exists', () => { const patient = { dataSources: [ { providerName: 'dexcom', state: 'disconnected', lastImportTime: '2024-02-01T00:00:00Z' }, - { providerName: 'dexcom', state: 'pendingReconnect' }, { providerName: 'dexcom', state: 'error' }, ], }; @@ -231,17 +296,6 @@ describe('getCurrentDataSourceForProvider', () => { expect(result.state).to.equal('error'); }); - it('should return pendingReconnect data source when no pending, connected, or error exists', () => { - const patient = { - dataSources: [ - { providerName: 'dexcom', state: 'disconnected', lastImportTime: '2024-02-01T00:00:00Z' }, - { providerName: 'dexcom', state: 'pendingReconnect' }, - ], - }; - const result = getCurrentDataSourceForProvider(patient, 'dexcom'); - expect(result.state).to.equal('pendingReconnect'); - }); - it('should return the disconnected data source with the most recent lastImportTime when only disconnected states exist', () => { const patient = { dataSources: [ @@ -289,9 +343,14 @@ describe('getCurrentDataSourceForProvider', () => { }); describe('getConnectStateUI', () => { + // getConnectStateUI returns the full UI map keyed by connectState identifier; the actual + // resolved state for a patient is the consumer's concern. These fixtures supply enough + // timing context for the UI map's messages to render. dataSource.state is one of + // ['connected', 'disconnected', 'error'] — 'disconnected' is the inert choice here + // since the fixtures aren't exercising state-driven branches. const clinicPatient = { id: 'patient123', - dataSources: [ { providerName: 'provider123', state: 'pending' }], + dataSources: [ { providerName: 'provider123', state: 'disconnected' }], connectionRequests: { provider123: [{ createdTime: moment.utc().subtract(20, 'days') }] }, }; @@ -304,7 +363,7 @@ describe('getConnectStateUI', () => { id: 'patient123', dataSources: [ { providerName: 'provider123', - state: 'pending', + state: 'disconnected', createdTime: moment.utc().subtract(20, 'days'), }], } @@ -313,7 +372,7 @@ describe('getConnectStateUI', () => { id: 'patient123', dataSources: [ { providerName: 'provider123', - state: 'pending', + state: 'disconnected', createdTime: moment.utc().subtract(20, 'days'), lastImportTime: moment.utc().subtract(10, 'days'), }], @@ -323,7 +382,7 @@ describe('getConnectStateUI', () => { id: 'patient123', dataSources: [ { providerName: 'provider123', - state: 'pending', + state: 'disconnected', createdTime: moment.utc().subtract(20, 'days'), modifiedTime: moment.utc().subtract(5, 'days'), lastImportTime: moment.utc().subtract(10, 'days'), @@ -428,17 +487,16 @@ describe('getConnectStateUI', () => { describe('getDataConnectionProps', () => { const setActiveHandlerStub = sinon.stub(); + // Pending state lives on connectionRequests, not on dataSources. const createPatientWithPendingDexcomConnection = () => ({ id: 'patient123', - dataSources: [ - { + connectionRequests: { + dexcom: [{ providerName: 'dexcom', - state: 'pending', - modifiedTime: moment.utc().subtract(2, 'days').toISOString(), - expirationTime: moment.utc().add(5, 'days').toISOString(), - }, - ], - connectionRequests: { dexcom: [{ createdTime: moment.utc().subtract(2, 'days').toISOString() }] }, + createdTime: moment.utc().subtract(2, 'days').toISOString(), + expirationTime: moment.utc().add(28, 'days').toISOString(), + }], + }, }); afterEach(() => { @@ -474,7 +532,6 @@ describe('getDataConnectionProps', () => { action: appActions.async.sendPatientDataProviderConnectRequest, args: [coreApi, 'clinic125', 'patient123', 'dexcom'], emailRequired: false, - patientUpdates: undefined, providerName: 'dexcom', connectState: 'pending', handler: 'resendInvite', @@ -511,22 +568,41 @@ describe('DataConnections', () => { const getDateInPast = (amount, unit) => moment.utc().subtract(amount, unit).toISOString(); - const patientWithState = (isClinicContext, state, opts = {}) => ({ - id: 'patient123', - dataSources: state ? map(availableProviders, providerName => ({ - providerName, - state, - createdTime: opts.createdTime, - modifiedTime: opts.modifiedTime, - expirationTime: opts.expirationTime, - lastImportTime: opts.lastImportTime, - latestDataTime: opts.latestDataTime, - })) : undefined, - connectionRequests: isClinicContext && opts.createdTime ? reduce(availableProviders, (res, providerName) => { - res[providerName] = [{ providerName, createdTime: opts.createdTime }]; - return res; - }, {}) : undefined, - }); + // Pending lifecycle is on patient.connectionRequests; established + // connections are on patient.dataSources. `pendingReconnect` carries both: a non- + // connected dataSource with modifiedTime older than the request's createdTime. + const requestDrivenStates = ['pending', 'pendingReconnect', 'pendingExpired']; + + const patientWithState = (isClinicContext, state, opts = {}) => { + const hasDataSource = !!state && !includes(['pending', 'pendingExpired'], state); + const dataSourceState = state === 'pendingReconnect' ? 'disconnected' : state; + const dataSourceModifiedTime = state === 'pendingReconnect' + ? moment.utc(opts.createdTime).subtract(1, 'day').toISOString() + : opts.modifiedTime; + const hasConnectionRequest = isClinicContext && includes(requestDrivenStates, state); + const requestExpirationTime = opts.expirationTime + || (opts.createdTime ? moment.utc(opts.createdTime).add(30, 'days').toISOString() : undefined); + + return { + id: 'patient123', + dataSources: hasDataSource ? map(availableProviders, providerName => ({ + providerName, + state: dataSourceState, + createdTime: opts.createdTime, + modifiedTime: dataSourceModifiedTime, + lastImportTime: opts.lastImportTime, + latestDataTime: opts.latestDataTime, + })) : undefined, + connectionRequests: hasConnectionRequest ? reduce(availableProviders, (res, providerName) => { + res[providerName] = [{ + providerName, + createdTime: opts.createdTime, + expirationTime: requestExpirationTime, + }]; + return res; + }, {}) : undefined, + }; + }; let clinicPatients; let userPatients; @@ -675,9 +751,11 @@ describe('DataConnections', () => { fireEvent.click(twiistActionButton); await waitFor(() => { - sinon.assert.calledWith(api.clinics.updateClinicPatient, 'clinicID123', 'patient123', sinon.match({ dataSources: [ { providerName: 'dexcom', state: 'pending' } ] })); - sinon.assert.calledWith(api.clinics.updateClinicPatient, 'clinicID123', 'patient123', sinon.match({ dataSources: [ { providerName: 'twiist', state: 'pending' } ] })); + sinon.assert.calledWith(api.clinics.sendPatientDataProviderConnectRequest, 'clinicID123', 'patient123', 'dexcom'); + sinon.assert.calledWith(api.clinics.sendPatientDataProviderConnectRequest, 'clinicID123', 'patient123', 'twiist'); }); + // No synthetic dataSources write — the connect-request endpoint is authoritative. + sinon.assert.notCalled(api.clinics.updateClinicPatient); }); }); diff --git a/test/unit/components/datasources/PatientEmailModal.test.js b/test/unit/components/datasources/PatientEmailModal.test.js index c3711c2f6d..84c1dca00f 100644 --- a/test/unit/components/datasources/PatientEmailModal.test.js +++ b/test/unit/components/datasources/PatientEmailModal.test.js @@ -106,7 +106,6 @@ describe('PatientEmailModal', () => { fullName: 'Patient 123', mrn: '12345', tags: ['tag1', 'tag2'], - dataSources: [], } })); store.clearActions(); @@ -150,7 +149,6 @@ describe('PatientEmailModal', () => { fullName: 'Patient 123', mrn: '12345', tags: ['tag1', 'tag2'], - dataSources: [], } })); store.clearActions(); diff --git a/test/unit/pages/ClinicPatients.test.js b/test/unit/pages/ClinicPatients.test.js index bcdf631acd..debc767988 100644 --- a/test/unit/pages/ClinicPatients.test.js +++ b/test/unit/pages/ClinicPatients.test.js @@ -293,9 +293,14 @@ describe('ClinicPatients', () => { fullName: 'patient1', birthDate: '1999-01-01', createdTime: '2021-10-19T16:27:59.504Z', - dataSources: [ - { providerName: 'dexcom', state: 'pending' }, - ], + // Pending lives on connectionRequests, not dataSources. + connectionRequests: { + dexcom: [{ + providerName: 'dexcom', + createdTime: '2021-10-19T16:30:00.000Z', + expirationTime: '2099-01-01T00:00:00.000Z', + }], + }, }, patient2: { id: 'patient2', @@ -348,9 +353,18 @@ describe('ClinicPatients', () => { email: 'patient7@test.ca', fullName: 'patient7', birthDate: '1999-01-01', + // PendingReconnect = non-connected dataSource + + // non-expired connectionRequest. dataSources: [ - { providerName: 'dexcom', state: 'pendingReconnect' }, + { providerName: 'dexcom', state: 'disconnected', modifiedTime: '2021-10-19T16:27:59.504Z' }, ], + connectionRequests: { + dexcom: [{ + providerName: 'dexcom', + createdTime: '2021-10-19T16:30:00.000Z', + expirationTime: '2099-01-01T00:00:00.000Z', + }], + }, }, }, },