Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,7 @@ locales/*/*_old.json
*.code-workspace
.pi
.pi-lens
.apo
.claude

# ApoVault — ignore entire vault locally
.apo/
1 change: 0 additions & 1 deletion app/components/clinic/PatientForm/PatientForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
157 changes: 85 additions & 72 deletions app/components/datasources/DataConnections.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ 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';
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';
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -156,15 +144,13 @@ export function getProviderHandlers(patient, selectedClinicId, provider) {
action: actions.async.sendPatientDataProviderConnectRequest,
args: [api, selectedClinicId, patient?.id, providerName],
emailRequired,
patientUpdates,
},
resendInvite: {
buttonText: t('Resend Invite'),
buttonStyle: 'solid',
action: actions.async.sendPatientDataProviderConnectRequest,
args: [api, selectedClinicId, patient?.id, providerName],
emailRequired,
patientUpdates,
},
}
};
Expand All @@ -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
Expand All @@ -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);

Expand All @@ -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;
Expand Down Expand Up @@ -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];
Expand All @@ -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;
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -434,7 +455,6 @@ export const DataConnections = (props) => {

setShowPatientEmailModal(false);
setProcessingEmailUpdate(false);
setPatientUpdates({});
setActiveHandler(null);
}
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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));
}
}
Expand All @@ -569,8 +578,6 @@ export const DataConnections = (props) => {
dispatch,
handleAddPatientEmailOpen,
handleResendDataSourceConnectEmailOpen,
patient,
selectedClinicId,
]);

useEffect(() => {
Expand Down Expand Up @@ -632,7 +639,7 @@ export const DataConnections = (props) => {
onClose={handleAddPatientEmailClose}
onFormChange={handleAddPatientEmailFormChange}
onSubmit={handleAddPatientEmailConfirm}
patient={{ ...patient, ...patientUpdates }}
patient={patient}
processing={processingEmailUpdate}
trackMetric={trackMetric}
/>}
Expand All @@ -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 = {
Expand All @@ -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,
Expand Down
7 changes: 0 additions & 7 deletions app/core/clinicUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
),
Expand Down
15 changes: 2 additions & 13 deletions app/pages/dashboard/TideDashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading