diff --git a/packages/ehr-utils/lib/fhir/constants.ts b/packages/ehr-utils/lib/fhir/constants.ts index 345824b55..9ef5e5699 100644 --- a/packages/ehr-utils/lib/fhir/constants.ts +++ b/packages/ehr-utils/lib/fhir/constants.ts @@ -7,4 +7,9 @@ export const FHIR_EXTENSION = { url: `${PRIVATE_EXTENSION_BASE_URL}/date-of-birth-not-confirmed`, }, }, + Patient: { + weight: { + url: `${PRIVATE_EXTENSION_BASE_URL}/weight`, + }, + }, }; diff --git a/packages/ehr-utils/lib/fhir/index.ts b/packages/ehr-utils/lib/fhir/index.ts index c55b82474..52681e609 100644 --- a/packages/ehr-utils/lib/fhir/index.ts +++ b/packages/ehr-utils/lib/fhir/index.ts @@ -1,6 +1,6 @@ import { BatchInputRequest, FhirClient } from '@zapehr/sdk'; import { Operation } from 'fast-json-patch'; -import { Coding, Patient, Person, Practitioner, RelatedPerson, Resource, Appointment } from 'fhir/r4'; +import { Coding, Patient, Person, Practitioner, RelatedPerson, Resource, Appointment, Extension } from 'fhir/r4'; import { FHIR_EXTENSION } from './constants'; export * from './chat'; @@ -70,6 +70,58 @@ export const getPatchOperationForNewMetaTag = (resource: Resource, newTag: Codin } }; +export const getPatchOperationToUpdateExtension = ( + resource: { extension?: Extension[] }, + newExtension: { + url: Extension['url']; + valueString?: Extension['valueString']; + valueBoolean?: Extension['valueBoolean']; + }, +): Operation | undefined => { + if (!resource.extension) { + return { + op: 'add', + path: '/extension', + value: [newExtension], + }; + } + + const extension = resource.extension; + let requiresUpdate = false; + + if (extension.length > 0) { + const existingExtIndex = extension.findIndex((ext) => ext.url === newExtension.url); + // check if formUser exists and needs to be updated and if so, update + if ( + existingExtIndex >= 0 && + (extension[existingExtIndex].valueString !== newExtension.valueString || + extension[existingExtIndex].valueBoolean !== newExtension.valueBoolean) + ) { + extension[existingExtIndex] = newExtension; + requiresUpdate = true; + } else if (existingExtIndex < 0) { + // if form user does not exist within the extension + // push to patientExtension array + extension.push(newExtension); + requiresUpdate = true; + } + } else { + // since no extensions exist, it must be added via patch operations + extension.push(newExtension); + requiresUpdate = true; + } + + if (requiresUpdate) { + return { + op: 'replace', + path: '/extension', + value: extension, + }; + } + + return undefined; +}; + export interface GetPatchBinaryInput { resourceId: string; resourceType: string; diff --git a/packages/ehr-utils/lib/helpers/practitioner.ts b/packages/ehr-utils/lib/helpers/practitioner.ts index b876aca95..e0a87d20d 100644 --- a/packages/ehr-utils/lib/helpers/practitioner.ts +++ b/packages/ehr-utils/lib/helpers/practitioner.ts @@ -1,5 +1,6 @@ -import { Encounter, Practitioner } from 'fhir/r4'; +import { Encounter, Identifier, Practitioner } from 'fhir/r4'; import { + FHIR_IDENTIFIER_NPI, PRACTITIONER_QUALIFICATION_CODE_SYSTEM, PRACTITIONER_QUALIFICATION_EXTENSION_URL, PRACTITIONER_QUALIFICATION_STATE_SYSTEM, @@ -54,3 +55,7 @@ export const checkIsEncounterForPractitioner = (encounter: Encounter, practition return !!practitioner && !!encounterPractitioner && practitionerId === encounterPractitionerId; }; + +export const getPractitionerNPIIdentitifier = (practitioner: Practitioner | undefined): Identifier | undefined => { + return practitioner?.identifier?.find((existIdentifier) => existIdentifier.system === FHIR_IDENTIFIER_NPI); +}; diff --git a/packages/ehr-utils/lib/types/api/chart-data/chart-data.types.ts b/packages/ehr-utils/lib/types/api/chart-data/chart-data.types.ts index ee4198547..8230d0c89 100644 --- a/packages/ehr-utils/lib/types/api/chart-data/chart-data.types.ts +++ b/packages/ehr-utils/lib/types/api/chart-data/chart-data.types.ts @@ -5,6 +5,7 @@ export interface ChartDataFields { ros?: FreeTextNoteDTO; conditions?: MedicalConditionDTO[]; medications?: MedicationDTO[]; + prescribedMedications?: PrescribedMedicationDTO[]; allergies?: AllergyDTO[]; procedures?: ProcedureDTO[]; proceduresNote?: FreeTextNoteDTO; @@ -41,6 +42,11 @@ export interface MedicationDTO extends SaveableDTO { id?: string; } +export interface PrescribedMedicationDTO extends SaveableDTO { + name?: string; + instructions?: string; +} + export interface ProcedureDTO extends SaveableDTO { name?: string; } diff --git a/packages/ehr-utils/lib/types/api/index.ts b/packages/ehr-utils/lib/types/api/index.ts index 3eb81f4cd..828cad28d 100644 --- a/packages/ehr-utils/lib/types/api/index.ts +++ b/packages/ehr-utils/lib/types/api/index.ts @@ -2,5 +2,6 @@ export * from './change-telemed-appointment-status'; export * from './chart-data'; export * from './get-telemed-appointments'; export * from './icd-search'; +export * from './sync-user'; export * from './init-telemed-session'; export * from './patient-instructions'; diff --git a/packages/ehr-utils/lib/types/api/sync-user/index.ts b/packages/ehr-utils/lib/types/api/sync-user/index.ts new file mode 100644 index 000000000..7b398fb61 --- /dev/null +++ b/packages/ehr-utils/lib/types/api/sync-user/index.ts @@ -0,0 +1 @@ +export * from './sync-user.types'; diff --git a/packages/ehr-utils/lib/types/api/sync-user/sync-user.types.ts b/packages/ehr-utils/lib/types/api/sync-user/sync-user.types.ts new file mode 100644 index 000000000..e574f495f --- /dev/null +++ b/packages/ehr-utils/lib/types/api/sync-user/sync-user.types.ts @@ -0,0 +1,4 @@ +export interface SyncUserResponse { + message: string; + updated: boolean; +} diff --git a/packages/ehr-utils/lib/types/constants.ts b/packages/ehr-utils/lib/types/constants.ts index 9ecb6a483..1c7e45169 100644 --- a/packages/ehr-utils/lib/types/constants.ts +++ b/packages/ehr-utils/lib/types/constants.ts @@ -1,5 +1,5 @@ export const TELEMED_VIDEO_ROOM_CODE = 'chime-video-meetings'; -export const PHOTON_PATIENT_IDENTIFIER_SYSTEM = 'http://api.zapehr.com/photon-patient-id'; +export const ERX_PATIENT_IDENTIFIER_SYSTEM = 'http://api.zapehr.com/photon-patient-id'; export const PRACTITIONER_QUALIFICATION_EXTENSION_URL = 'http://hl7.org/fhir/us/davinci-pdex-plan-net/StructureDefinition/practitioner-qualification'; diff --git a/packages/ehr-utils/lib/types/practitioner.types.ts b/packages/ehr-utils/lib/types/practitioner.types.ts index dfdbc1369..af0260277 100644 --- a/packages/ehr-utils/lib/types/practitioner.types.ts +++ b/packages/ehr-utils/lib/types/practitioner.types.ts @@ -281,3 +281,6 @@ export interface PractitionerLicense { code: PractitionerQualificationCode; active: boolean; } + +export const ERX_PRESCRIBER_SYSTEM_URL = 'http://api.zapehr.com/photon-prescriber-id'; +export const ERX_PRACTITIONER_ENROLLED = 'http://api.zapehr.com/photon-practitioner-enrolled'; diff --git a/packages/telemed-ehr/app/env/.env.local-template b/packages/telemed-ehr/app/env/.env.local-template index bc9e23acc..b9231c629 100644 --- a/packages/telemed-ehr/app/env/.env.local-template +++ b/packages/telemed-ehr/app/env/.env.local-template @@ -18,6 +18,7 @@ VITE_APP_GET_APPOINTMENTS_ZAMBDA_ID=get-appointments VITE_APP_CREATE_APPOINTMENT_ZAMBDA_ID=create-appointment VITE_APP_UPDATE_USER_ZAMBDA_ID=update-user VITE_APP_GET_USER_ZAMBDA_ID=get-user +VITE_APP_SYNC_USER_ZAMBDA_ID=sync-user VITE_APP_DEACTIVATE_USER_ZAMBDA_ID=deactivate-user VITE_APP_GET_EMPLOYEES_ZAMBDA_ID=get-employees VITE_APP_GET_TELEMED_APPOINTMENTS_ZAMBDA_ID=get-telemed-appointments @@ -28,4 +29,6 @@ VITE_APP_CHANGE_TELEMED_APPOINTMENT_STATUS_ZAMBDA_ID=change-telemed-appointment- VITE_APP_DELETE_CHART_DATA_ZAMBDA_ID=delete-chart-data VITE_APP_GET_TOKEN_FOR_CONVERSATION_ZAMBDA_ID=get-token-for-conversation VITE_APP_CANCEL_TELEMED_APPOINTMENT_ZAMBDA_ID=cancel-appointment -VITE_APP_QRS_URL=http://localhost:3002 \ No newline at end of file +VITE_APP_QRS_URL=http://localhost:3002 +VITE_APP_PHOTON_ORG_ID= +VITE_APP_PHOTON_CLIENT_ID= diff --git a/packages/telemed-ehr/app/index.html b/packages/telemed-ehr/app/index.html index 62bda6bf9..7cd5c13ab 100644 --- a/packages/telemed-ehr/app/index.html +++ b/packages/telemed-ehr/app/index.html @@ -2,11 +2,11 @@ - + - + Ottehr EHR diff --git a/packages/telemed-ehr/app/package.json b/packages/telemed-ehr/app/package.json index 5b4c2bd04..6c3c40c8e 100644 --- a/packages/telemed-ehr/app/package.json +++ b/packages/telemed-ehr/app/package.json @@ -37,12 +37,13 @@ "@mui/x-data-grid-pro": "^6.3.0", "@mui/x-date-pickers": "^5.0.20", "@mui/x-date-pickers-pro": "^5.0.20", - "@photonhealth/elements": "^0.9.1-rc.1", + "@photonhealth/elements": "^0.12.2", "@twilio/conversations": "^2.4.1", "@zapehr/sdk": "1.0.15", "amazon-chime-sdk-component-library-react": "^3.7.0", "amazon-chime-sdk-js": "^3.20.0", "chart.js": "^4.4.1", + "notistack": "^3.0.1", "fast-json-patch": "^3.1.1", "react-chartjs-2": "^5.2.0", "react-draggable": "^4.4.6", diff --git a/packages/telemed-ehr/app/public/manifest.json b/packages/telemed-ehr/app/public/manifest.json index 92f769d36..d85ef579d 100644 --- a/packages/telemed-ehr/app/public/manifest.json +++ b/packages/telemed-ehr/app/public/manifest.json @@ -3,8 +3,8 @@ "name": "Ottehr EHR", "icons": [ { - "src": "favicon.ico", - "sizes": "32x32 16x16", + "src": "ottehr-icon.ico", + "sizes": "64x64 32x32 24x24 16x16", "type": "image/x-icon" } ], diff --git a/packages/telemed-ehr/app/public/ottehr-icon-256.png b/packages/telemed-ehr/app/public/ottehr-icon-256.png new file mode 100644 index 000000000..dbf10a9bc Binary files /dev/null and b/packages/telemed-ehr/app/public/ottehr-icon-256.png differ diff --git a/packages/telemed-ehr/app/public/ottehr-icon.ico b/packages/telemed-ehr/app/public/ottehr-icon.ico new file mode 100644 index 000000000..b82190225 Binary files /dev/null and b/packages/telemed-ehr/app/public/ottehr-icon.ico differ diff --git a/packages/telemed-ehr/app/src/App.tsx b/packages/telemed-ehr/app/src/App.tsx index c5c1c5253..a6101c565 100644 --- a/packages/telemed-ehr/app/src/App.tsx +++ b/packages/telemed-ehr/app/src/App.tsx @@ -8,7 +8,7 @@ import { LoadingScreen } from './components/LoadingScreen'; import Navbar from './components/navigation/Navbar'; import { ProtectedRoute } from './components/routing/ProtectedRoute'; import { useApiClients } from './hooks/useAppClients'; -import useOttehrUser from './hooks/useOttehrUser'; +import useOttehrUser, { useProviderERXStateStore } from './hooks/useOttehrUser'; import AddPatient from './pages/AddPatient'; import AppointmentsPage from './pages/Appointments'; import EditEmployeePage from './pages/EditEmployee'; @@ -22,18 +22,12 @@ import { TelemedAdminPage } from './pages/TelemedAdminPage'; import { useNavStore } from './state/nav.store'; import EditInsurance from './telemed/features/telemed-admin/EditInsurance'; import EditStatePage from './telemed/features/telemed-admin/EditState'; -import { isLocalOrDevOrTestingOrTrainingEnv } from './telemed/utils/env.helper'; import { RoleType } from './types/types'; import { AppointmentPage } from './pages/AppointmentPage'; import AddSchedulePage from './pages/AddSchedulePage'; +import('@photonhealth/elements').catch(console.log); import Version from './pages/Version'; -const enablePhoton = false && isLocalOrDevOrTestingOrTrainingEnv; - -if (enablePhoton) { - import('@photonhealth/elements').catch(console.log); -} - const TelemedTrackingBoardPageLazy = lazy(async () => { const TrackingBoardPage = await import('./telemed/pages/TrackingBoardPage'); return { default: TrackingBoardPage.TrackingBoardPage }; @@ -56,6 +50,8 @@ function App(): ReactElement { const currentUser = useOttehrUser(); const currentTab = useNavStore((state: any) => state.currentTab) || 'In Person'; + const wasEnrolledInERX = useProviderERXStateStore((state) => state.wasEnrolledInERX); + const roleUnknown = !currentUser || !currentUser.hasRole([RoleType.Administrator, RoleType.Staff, RoleType.Manager, RoleType.Provider]); @@ -72,11 +68,12 @@ function App(): ReactElement { - {currentUser?.hasRole([RoleType.Provider]) && enablePhoton ? ( + {(currentUser?.hasRole([RoleType.Provider]) && currentUser.isPractitionerEnrolledInERX) || + wasEnrolledInERX ? ( diff --git a/packages/telemed-ehr/app/src/hooks/useOttehrUser.tsx b/packages/telemed-ehr/app/src/hooks/useOttehrUser.tsx index a55bba73e..bd3324ed7 100644 --- a/packages/telemed-ehr/app/src/hooks/useOttehrUser.tsx +++ b/packages/telemed-ehr/app/src/hooks/useOttehrUser.tsx @@ -2,10 +2,21 @@ import { useAuth0 } from '@auth0/auth0-react'; import { ClientConfig, FhirClient } from '@zapehr/sdk'; import { Practitioner } from 'fhir/r4'; import { DateTime, Duration } from 'luxon'; -import { useEffect, useMemo } from 'react'; -import { useQuery } from 'react-query'; -import { User, getFullestAvailableName, getPatchOperationForNewMetaTag, initialsFromName } from 'ehr-utils'; +import { Operation } from 'fast-json-patch'; +import { useCallback, useEffect, useMemo } from 'react'; +import { useMutation, useQuery, useQueryClient } from 'react-query'; +import { + ERX_PRACTITIONER_ENROLLED, + ERX_PRESCRIBER_SYSTEM_URL, + SyncUserResponse, + User, + getFullestAvailableName, + getPatchOperationToUpdateExtension, + getPractitionerNPIIdentitifier, + initialsFromName, +} from 'ehr-utils'; import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; import { getUser } from '../api/api'; import { useZapEHRAPIClient } from '../telemed/hooks/useZapEHRAPIClient'; import { RoleType } from '../types/types'; @@ -17,6 +28,8 @@ export interface OttehrUser extends User { userInitials: string; lastLogin: string | undefined; hasRole: (role: RoleType[]) => boolean; + isERXPrescriber?: boolean; + isPractitionerEnrolledInERX?: boolean; } interface OttehrUserState { @@ -32,17 +45,67 @@ enum LoadingState { idle, } -// extracting it here, cause even if we use store - it will still initiate requests as much as we have usages of this hook, -// so just to use this var as a synchronization mechanism - lifted it here +export const useProviderERXStateStore = create<{ + wasEnrolledInERX?: boolean; +}>()(persist(() => ({}), { name: 'ottehr-ehr-provider-erx-store' })); let _profileLoadingState = LoadingState.initial; -let _lastLoginUpdating = LoadingState.initial; let _userLoading = LoadingState.initial; +let _practitionerSyncStarted = false; +let _practitionerSyncFinished = false; +let _practitionerERXEnrollmentStarted = false; export default function useOttehrUser(): OttehrUser | undefined { const { fhirClient } = useApiClients(); const { isAuthenticated, getAccessTokenSilently, user: auth0User } = useAuth0(); const user = useOttehrUserStore((state) => state.user); const profile = useOttehrUserStore((state) => state.profile); + const isERXPrescriber = profile?.identifier?.some((x) => x.system === ERX_PRESCRIBER_SYSTEM_URL && Boolean(x.value)); + const isPractitionerEnrolledInERX = profile?.extension?.some( + (x) => x.url === ERX_PRACTITIONER_ENROLLED && Boolean(x.valueBoolean), + ); + + useEffect(() => { + if (isPractitionerEnrolledInERX) { + useProviderERXStateStore.setState({ wasEnrolledInERX: true }); + } + }, [isPractitionerEnrolledInERX]); + + const isProviderHasEverythingToBeEnrolledInErx = Boolean( + profile?.id && + profile?.telecom?.find((phone) => phone.system === 'sms' || phone.system === 'phone')?.value && + getPractitionerNPIIdentitifier(profile)?.value && + profile?.name?.[0]?.given?.[0] && + profile?.name?.[0]?.family, + ); + // console.log( + // `profile id: ${profile?.id} + // & NPI: ${getPractitionerNPIIdentitifier(profile)?.value} + // & phone: ${profile?.telecom?.find((phone) => phone.system === 'sms' || phone.system === 'phone')?.value} + // & given name: ${profile?.name?.[0]?.given?.[0]} + // & family name: ${profile?.name?.[0]?.family}`, + // ); + // console.log( + // isProviderHasEverythingToBeEnrolledInErx + // ? 'Provider has everything to be enrolled in ERX' + // : 'Provider NOT ready to be enrolled in ERX', + // ); + + const userRoles = user?.roles; + const hasRole = useCallback( + (role: RoleType[]): boolean => { + return userRoles?.find((r) => role.includes(r.name as RoleType)) != undefined; + }, + [userRoles], + ); + useGetUser(); + useSyncPractitioner((data) => { + if (data.updated) { + console.log('Practitioner sync success'); + } + }); + const { refetch: refetchProfile } = useGetProfile(); + const { mutateAsync: mutatePractitionerAsync } = useUpdatePractitioner(); + const { mutateAsync: mutateEnrollPractitionerInERX } = useEnrollPractitionerInERX(); useEffect(() => { async function getUserRequest(): Promise { @@ -87,32 +150,6 @@ export default function useOttehrUser(): OttehrUser | undefined { } }, [fhirClient, profile, user]); - useEffect(() => { - async function updateLastLogin(user: User, fhirClient: FhirClient): Promise { - _lastLoginUpdating = LoadingState.pending; - try { - await fhirClient.patchResource({ - resourceType: 'Practitioner', - resourceId: user.profile.replace('Practitioner/', ''), - operations: [ - getPatchOperationForNewMetaTag(profile!, { - system: 'last-login', - code: DateTime.now().toISO() ?? 'Unknown', - }), - ], - }); - } catch (error) { - console.log(error); - } finally { - _lastLoginUpdating = LoadingState.idle; - } - } - - if (user && fhirClient && profile && _lastLoginUpdating !== LoadingState.pending) { - void updateLastLogin(user, fhirClient); - } - }, [fhirClient, profile, user]); - useEffect(() => { const lastLogin = auth0User?.updated_at; if (lastLogin) { @@ -123,6 +160,40 @@ export default function useOttehrUser(): OttehrUser | undefined { } }, [auth0User?.updated_at]); + useEffect(() => { + if ( + !isPractitionerEnrolledInERX && + hasRole([RoleType.Provider]) && + _practitionerSyncFinished && + isProviderHasEverythingToBeEnrolledInErx && + !_practitionerERXEnrollmentStarted + ) { + _practitionerERXEnrollmentStarted = true; + mutateEnrollPractitionerInERX() + .then(async () => { + if (profile) { + const op = getPatchOperationToUpdateExtension(profile, { + url: ERX_PRACTITIONER_ENROLLED, + valueBoolean: true, + }); + if (op) { + await mutatePractitionerAsync([op]); + void refetchProfile(); + } + } + }) + .catch(console.error); + } + }, [ + hasRole, + isPractitionerEnrolledInERX, + isProviderHasEverythingToBeEnrolledInErx, + mutateEnrollPractitionerInERX, + mutatePractitionerAsync, + profile, + refetchProfile, + ]); + const { userName, userInitials, lastLogin } = useMemo(() => { if (profile) { const userName = getFullestAvailableName(profile) ?? 'Ottehr Team'; @@ -142,14 +213,189 @@ export default function useOttehrUser(): OttehrUser | undefined { userInitials, lastLogin, profileResource: profile, + isERXPrescriber, + isPractitionerEnrolledInERX, hasRole: (role: RoleType[]) => { return userRoles.find((r) => role.includes(r.name as RoleType)) != undefined; }, }; } return undefined; - }, [lastLogin, profile, user, userInitials, userName]); + }, [lastLogin, isERXPrescriber, isPractitionerEnrolledInERX, profile, user, userInitials, userName]); } const MINUTE = 1000 * 60; const DAY = MINUTE * 60 * 24; + +interface StreetAddress { + street1: string; + street2?: string; + city: string; + state: string; + postal_code: string; +} + +interface ERXEnrollmentProps { + providerId: Required; + address: StreetAddress; + npi?: string; + phone: string; + given_name: string; + family_name: string; +} + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +const useEnrollPractitionerInERX = () => { + const token = useAuthToken(); + const profile = useOttehrUserStore((state) => state.profile); + + return useMutation( + ['enroll-provider-erx'], + async (): Promise => { + try { + const address: StreetAddress = { + street1: '1 Hollow Lane', + street2: 'Ste 301', + city: 'Lake Success', + postal_code: '11042', + state: 'NY', + }; + const payload: ERXEnrollmentProps = { + providerId: profile?.id, + address, + phone: profile?.telecom?.find((phone) => phone.system === 'sms' || phone.system === 'phone')?.value || '', + npi: profile && getPractitionerNPIIdentitifier(profile)?.value, + given_name: profile?.name?.[0]?.given?.[0] || '', + family_name: profile?.name?.[0]?.family || '', + }; + _practitionerERXEnrollmentStarted = true; + + const response = await fetch(`${import.meta.env.VITE_APP_PROJECT_API_URL}/erx/enrollment`, { + headers: { + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(payload), + method: 'POST', + }); + if (!response.ok) { + throw new Error(`ERX practitioner enrollment call failed: ${response.statusText}`); + } + } catch (error) { + console.error(error); + throw error; + } + }, + { retry: 2 }, + ); +}; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +const useGetUser = () => { + const token = useAuthToken(); + const user = useOttehrUserStore((state) => state.user); + + return useQuery( + ['get-user'], + async (): Promise => { + try { + const user = await getUser(token!); + useOttehrUserStore.setState({ user: user as User }); + } catch (error) { + console.error(error); + } + }, + { + enabled: Boolean(token && !user), + }, + ); +}; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +const useGetProfile = () => { + const token = useAuthToken(); + const user = useOttehrUserStore((state) => state.user); + const profile = useOttehrUserStore((state) => state.profile); + const { fhirClient } = useApiClients(); + + return useQuery( + ['get-practitioner-profile'], + async (): Promise => { + try { + if (!user?.profile) { + useOttehrUserStore.setState({ profile: undefined }); + return; + } + + const [resourceType, resourceId] = (user?.profile || '').split('/'); + if (resourceType && resourceId && resourceType === 'Practitioner') { + const practitioner = await fhirClient?.readResource({ resourceType, resourceId }); + useOttehrUserStore.setState({ profile: practitioner }); + console.log('practitioner', practitioner); + } + } catch (e) { + console.error(`error fetching user's fhir profile: ${JSON.stringify(e)}`); + useOttehrUserStore.setState({ profile: undefined }); + } + }, + { + enabled: Boolean(token && fhirClient && user?.profile && !profile), + }, + ); +}; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +const useSyncPractitioner = (onSuccess: (data: SyncUserResponse) => void) => { + const client = useZapEHRAPIClient(); + const token = useAuthToken(); + const { zambdaClient } = useApiClients(); + const queryClient = useQueryClient(); + return useQuery( + ['sync-user', zambdaClient], + async () => { + console.log('zambdaClient: ', zambdaClient); + if (!client) return undefined; + _practitionerSyncStarted = true; + const result = await client?.syncUser(); + _practitionerSyncFinished = true; + if (result.updated) { + void queryClient.refetchQueries('get-practitioner-profile'); + } else { + useOttehrUserStore.setState((state) => ({ profile: { ...(state.profile! || {}) } })); + } + return result; + }, + { + onSuccess, + cacheTime: DAY, + staleTime: DAY, + enabled: Boolean( + token && zambdaClient && (zambdaClient as unknown as ClientConfig).accessToken && !_practitionerSyncStarted, + ), + }, + ); +}; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +const useUpdatePractitioner = () => { + const user = useOttehrUserStore((state) => state.user); + const { fhirClient } = useApiClients(); + + return useMutation( + ['update-practitioner'], + async (patchOps: Operation[]): Promise => { + try { + if (!fhirClient || !user) return; + + await fhirClient.patchResource({ + resourceType: 'Practitioner', + resourceId: user.profile.replace('Practitioner/', ''), + operations: [...patchOps], + }); + } catch (error) { + console.error(error); + throw error; + } + }, + { retry: 3 }, + ); +}; diff --git a/packages/telemed-ehr/app/src/index.tsx b/packages/telemed-ehr/app/src/index.tsx index 8b529b4ce..2ba9d0cf4 100644 --- a/packages/telemed-ehr/app/src/index.tsx +++ b/packages/telemed-ehr/app/src/index.tsx @@ -29,6 +29,8 @@ root.render( audience={import.meta.env.VITE_APP_ZAPEHR_APPLICATION_AUDIENCE} redirectUri={AUTH0_REDIRECT_URI} connection={import.meta.env.VITE_APP_ZAPEHR_CONNECTION_NAME} + cacheLocation="localstorage" + skipRedirectCallback={window.location.href.includes('photon=true')} > diff --git a/packages/telemed-ehr/app/src/photon.d.ts b/packages/telemed-ehr/app/src/photon.d.ts index 56e88de24..b92bba74c 100644 --- a/packages/telemed-ehr/app/src/photon.d.ts +++ b/packages/telemed-ehr/app/src/photon.d.ts @@ -8,6 +8,11 @@ declare namespace JSX { 'redirect-uri': string; children: Element; }; - 'photon-prescribe-workflow': { 'enable-order': string; 'patient-id'?: string }; + 'photon-prescribe-workflow': { + 'enable-order': string; + 'patient-id'?: string; + weight?: number; + 'weight-unit'?: 'lbs' | 'kg'; + }; } } diff --git a/packages/telemed-ehr/app/src/telemed/data/types.ts b/packages/telemed-ehr/app/src/telemed/data/types.ts index a4f0f16b6..107ea1a90 100644 --- a/packages/telemed-ehr/app/src/telemed/data/types.ts +++ b/packages/telemed-ehr/app/src/telemed/data/types.ts @@ -6,6 +6,7 @@ export type GetZapEHRTelemedAPIParams = { saveChartDataZambdaID?: string; deleteChartDataZambdaID?: string; changeTelemedAppointmentStatusZambdaID?: string; + syncUserZambdaID?: string; getPatientInstructionsZambdaID?: string; savePatientInstructionZambdaID?: string; deletePatientInstructionZambdaID?: string; diff --git a/packages/telemed-ehr/app/src/telemed/data/zapEHRApi.ts b/packages/telemed-ehr/app/src/telemed/data/zapEHRApi.ts index 3a3f11cbd..8373408a4 100644 --- a/packages/telemed-ehr/app/src/telemed/data/zapEHRApi.ts +++ b/packages/telemed-ehr/app/src/telemed/data/zapEHRApi.ts @@ -17,6 +17,7 @@ import { SaveChartDataRequest, SaveChartDataResponse, SavePatientInstructionInput, + SyncUserResponse, } from 'ehr-utils'; import { GetAppointmentsRequestParams } from '../utils'; import { ApiError, GetZapEHRTelemedAPIParams } from './types'; @@ -28,6 +29,7 @@ enum ZambdaNames { 'save chart data' = 'save chart data', 'delete chart data' = 'delete chart data', 'change telemed appointment status' = 'change telemed appointment status', + 'sync user' = 'sync user', 'get patient instructions' = 'get patient instructions', 'save patient instruction' = 'save patient instruction', 'delete patient instruction' = 'delete patient instruction', @@ -41,6 +43,7 @@ const zambdasPublicityMap: Record = { 'save chart data': false, 'delete chart data': false, 'change telemed appointment status': false, + 'sync user': false, 'get patient instructions': false, 'save patient instruction': false, 'delete patient instruction': false, @@ -59,6 +62,7 @@ export const getZapEHRTelemedAPI = ( saveChartData: typeof saveChartData; deleteChartData: typeof deleteChartData; changeTelemedAppointmentStatus: typeof changeTelemedAppointmentStatus; + syncUser: typeof syncUser; getPatientInstructions: typeof getPatientInstructions; savePatientInstruction: typeof savePatientInstruction; deletePatientInstruction: typeof deletePatientInstruction; @@ -71,6 +75,7 @@ export const getZapEHRTelemedAPI = ( saveChartDataZambdaID, deleteChartDataZambdaID, changeTelemedAppointmentStatusZambdaID, + syncUserZambdaID, getPatientInstructionsZambdaID, savePatientInstructionZambdaID, deletePatientInstructionZambdaID, @@ -84,6 +89,7 @@ export const getZapEHRTelemedAPI = ( 'save chart data': saveChartDataZambdaID, 'delete chart data': deleteChartDataZambdaID, 'change telemed appointment status': changeTelemedAppointmentStatusZambdaID, + 'sync user': syncUserZambdaID, 'get patient instructions': getPatientInstructionsZambdaID, 'save patient instruction': savePatientInstructionZambdaID, 'delete patient instruction': deletePatientInstructionZambdaID, @@ -171,6 +177,10 @@ export const getZapEHRTelemedAPI = ( return await makeZapRequest('change telemed appointment status', parameters); }; + const syncUser = async (): Promise => { + return await makeZapRequest('sync user'); + }; + const getPatientInstructions = async (parameters: GetPatientInstructionsInput): Promise => { return await makeZapRequest('get patient instructions', parameters); }; @@ -194,6 +204,7 @@ export const getZapEHRTelemedAPI = ( saveChartData, deleteChartData, changeTelemedAppointmentStatus, + syncUser, getPatientInstructions, savePatientInstruction, deletePatientInstruction, diff --git a/packages/telemed-ehr/app/src/telemed/features/appointment/AppointmentHeader.tsx b/packages/telemed-ehr/app/src/telemed/features/appointment/AppointmentHeader.tsx index fbb74ba2c..59add7146 100644 --- a/packages/telemed-ehr/app/src/telemed/features/appointment/AppointmentHeader.tsx +++ b/packages/telemed-ehr/app/src/telemed/features/appointment/AppointmentHeader.tsx @@ -1,11 +1,31 @@ -import { FC } from 'react'; -import { AppBar, Box, IconButton, useTheme } from '@mui/material'; +import { AppBar, Box, Container, IconButton, Skeleton, Typography, useTheme } from '@mui/material'; +import { DateTime } from 'luxon'; +import { FC, useState } from 'react'; +import { ERX_PATIENT_IDENTIFIER_SYSTEM, mapStatusToTelemed, getQuestionnaireResponseByLinkId } from 'ehr-utils'; +import CustomBreadcrumbs from '../../../components/CustomBreadcrumbs'; +import { getSelectors } from '../../../shared/store/getSelectors'; +import CancelVisitDialog from '../../components/CancelVisitDialog'; +import { useAppointmentStore } from '../../state'; +import { getAppointmentStatusChip, getPatientName } from '../../utils'; import CloseIcon from '@mui/icons-material/Close'; import { useNavigate } from 'react-router-dom'; import { AppointmentTabsHeader } from './AppointmentTabsHeader'; export const AppointmentHeader: FC = () => { const theme = useTheme(); + + const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [isERXOpen, setIsERXOpen] = useState(false); + + const { appointment, encounter, patient, location, isReadOnly, questionnaireResponse } = getSelectors( + useAppointmentStore, + ['appointment', 'patient', 'encounter', 'location', 'isReadOnly', 'questionnaireResponse'], + ); + + const patientPhotonId = patient?.identifier?.find((id) => id.system === ERX_PATIENT_IDENTIFIER_SYSTEM)?.value; + const reasonForVisit = getQuestionnaireResponseByLinkId('reason-for-visit', questionnaireResponse)?.answer?.[0] + .valueString; const navigate = useNavigate(); return ( diff --git a/packages/telemed-ehr/app/src/telemed/features/appointment/AssessmentTab/ERxCard.tsx b/packages/telemed-ehr/app/src/telemed/features/appointment/AssessmentTab/ERxCard.tsx index 7476e1f44..5747e773d 100644 --- a/packages/telemed-ehr/app/src/telemed/features/appointment/AssessmentTab/ERxCard.tsx +++ b/packages/telemed-ehr/app/src/telemed/features/appointment/AssessmentTab/ERxCard.tsx @@ -1,19 +1,38 @@ -import React, { FC } from 'react'; import { Box } from '@mui/material'; -import { AccordionCard, RoundedButton } from '../../../components'; +import { FC, useCallback, useState } from 'react'; import { getSelectors } from '../../../../shared/store/getSelectors'; +import { AccordionCard, RoundedButton } from '../../../components'; import { useAppointmentStore } from '../../../state'; +import { ERX } from '../ERX'; +import { PrescribedMedicationReviewItem } from '../ReviewTab/components/PrescribedMedicationReviewItem'; export const ERxCard: FC = () => { - const { isReadOnly } = getSelectors(useAppointmentStore, ['isReadOnly']); + const [collapsed, setCollapsed] = useState(false); + const { isReadOnly, chartData } = getSelectors(useAppointmentStore, ['isReadOnly', 'chartData']); + + const [isERXOpen, setIsERXOpen] = useState(false); + const [isERXLoading, setIsERXLoading] = useState(false); + + const handleERXLoadingStatusChange = useCallback<(status: boolean) => void>( + (status) => setIsERXLoading(status), + [setIsERXLoading], + ); return ( - - - - Add eRx + setCollapsed((prevState) => !prevState)}> + + {(chartData?.prescribedMedications?.length || -1) >= 0 && ( + + {chartData?.prescribedMedications?.map((med) => ( + + ))} + + )} + setIsERXOpen(true)}> + Add RX + {isERXOpen && setIsERXOpen(false)} onLoadingStatusChange={handleERXLoadingStatusChange} />} ); }; diff --git a/packages/telemed-ehr/app/src/telemed/features/appointment/ERX.tsx b/packages/telemed-ehr/app/src/telemed/features/appointment/ERX.tsx new file mode 100644 index 000000000..50d0362cf --- /dev/null +++ b/packages/telemed-ehr/app/src/telemed/features/appointment/ERX.tsx @@ -0,0 +1,78 @@ +import { Patient } from 'fhir/r4'; +import { enqueueSnackbar } from 'notistack'; +import { FC, useCallback, useEffect, useState } from 'react'; +import { ERX_PATIENT_IDENTIFIER_SYSTEM } from 'ehr-utils'; +import { getSelectors } from '../../../shared/store/getSelectors'; +import { useAppointmentStore, useSyncERXPatient } from '../../state'; +import { ERXDialog } from './ERXDialog'; + +export const ERX: FC<{ + onClose: () => void; + onLoadingStatusChange: (loading: boolean) => void; +}> = ({ onClose, onLoadingStatusChange }) => { + const { patient } = getSelectors(useAppointmentStore, ['patient']); + const phoneNumber = patient?.telecom?.find((telecom) => telecom.system === 'phone')?.value; + const [syncedPatient, setSyncedPatient] = useState(false); + + const { isLoading: isSyncingPatient, mutateAsync: syncPatient, isError } = useSyncERXPatient(); + + const photonPatientId = patient?.identifier?.find((id) => id.system === ERX_PATIENT_IDENTIFIER_SYSTEM)?.value; + + const syncPatientFn = useCallback(async () => { + try { + const response = await syncPatient(patient!); + if (response.photonPatientId) { + useAppointmentStore.setState((state) => { + const oldPatient = (state.patient || {}) as Patient; + return { + patient: { + ...oldPatient, + identifier: [ + ...(oldPatient?.identifier || []), + { system: ERX_PATIENT_IDENTIFIER_SYSTEM, value: response.photonPatientId }, + ], + }, + }; + }); + } + setSyncedPatient(true); + } catch (err: any) { + let errorMsg = 'Something went wrong while trying to open RX'; + + if (err.status === 400) { + if (err.message && err.message.includes('phone')) { + errorMsg = `Patient has specified some wrong phone number: ${phoneNumber}. RX can be used only when a real phone number is provided`; + } else { + errorMsg = `Something is wrong with patient data.`; + } + } + + enqueueSnackbar(errorMsg, { + variant: 'error', + }); + + console.error('Error trying to sync patient to photon: ', err); + } + }, [patient, phoneNumber, syncPatient]); + + useEffect(() => { + onLoadingStatusChange(isSyncingPatient); + }, [onLoadingStatusChange, isSyncingPatient]); + + useEffect(() => { + if (isError) { + onClose(); + } + if (!syncedPatient && patient && !isSyncingPatient && !isError) { + void syncPatientFn(); + } + }, [syncedPatient, isError, isSyncingPatient, onClose, patient, syncPatientFn]); + + return ( + <> + {photonPatientId && !isSyncingPatient && ( + onClose()} /> + )} + + ); +}; diff --git a/packages/telemed-ehr/app/src/telemed/features/appointment/ERXDialog.tsx b/packages/telemed-ehr/app/src/telemed/features/appointment/ERXDialog.tsx index 15434e841..8985af6ff 100644 --- a/packages/telemed-ehr/app/src/telemed/features/appointment/ERXDialog.tsx +++ b/packages/telemed-ehr/app/src/telemed/features/appointment/ERXDialog.tsx @@ -1,5 +1,15 @@ -import { Dialog } from '@mui/material'; -import { ReactElement } from 'react'; +import CloseIcon from '@mui/icons-material/Close'; +import { Box, Dialog, DialogProps, IconButton } from '@mui/material'; +import { ReactElement, useEffect, useMemo } from 'react'; +import { PrescribedMedicationDTO } from 'ehr-utils'; +import { FHIR_EXTENSION } from 'ehr-utils/lib/fhir/constants'; +import { getSelectors } from '../../../shared/store/getSelectors'; +import { useAppointmentStore } from '../../state'; + +interface PhotonPrescription { + treatment: { id: string; name: string }; + instructions: string; +} export const ERXDialog = ({ onClose, @@ -8,20 +18,94 @@ export const ERXDialog = ({ onClose: () => void; patientPhotonId?: string; }): ReactElement => { + const { patient, setPartialChartData, chartData } = getSelectors(useAppointmentStore, [ + 'patient', + 'setPartialChartData', + 'chartData', + ]); + let weight: number | undefined = Number.parseFloat( + patient?.extension?.find((ext) => ext.url === FHIR_EXTENSION.Patient.weight.url)?.valueString ?? '', + ); + if (isNaN(weight)) { + weight = undefined; + } + + const existingPrescribedMeds = useMemo( + () => chartData?.prescribedMedications || [], + [chartData?.prescribedMedications], + ); + + useEffect(() => { + const photonListener = (e: Event): void => { + const prescriptionsEvent = e as unknown as { detail?: { prescriptions?: PhotonPrescription[] } }; + const prescribedMeds = + prescriptionsEvent.detail?.prescriptions?.map( + (detail) => + ({ + name: detail.treatment.name, + instructions: detail.instructions, + }) as PrescribedMedicationDTO, + ) || []; + if (prescribedMeds.length > 0) { + setPartialChartData({ + prescribedMedications: [ + ...existingPrescribedMeds, + ...prescribedMeds.filter( + (newMed) => + existingPrescribedMeds.findIndex( + (existingMed) => existingMed.instructions === newMed.instructions && existingMed.name === newMed.name, + ) < 0, + ), + ], + }); + } + onClose(); + }; + document.addEventListener('photon-prescriptions-created', photonListener); + + return () => { + document.removeEventListener('photon-prescriptions-created', photonListener); + }; + }, [existingPrescribedMeds, onClose, setPartialChartData]); + + const handleClose: DialogProps['onClose'] = (_, reason) => { + if (reason === 'backdropClick') return; + onClose(); + }; return ( - + + onClose()}> + + + + ); }; diff --git a/packages/telemed-ehr/app/src/telemed/features/appointment/ReviewTab/components/PrescribedMedicationReviewItem.tsx b/packages/telemed-ehr/app/src/telemed/features/appointment/ReviewTab/components/PrescribedMedicationReviewItem.tsx new file mode 100644 index 000000000..483f18c8d --- /dev/null +++ b/packages/telemed-ehr/app/src/telemed/features/appointment/ReviewTab/components/PrescribedMedicationReviewItem.tsx @@ -0,0 +1,16 @@ +import { Typography } from '@mui/material'; +import { FC } from 'react'; +import { PrescribedMedicationDTO } from 'ehr-utils'; + +export const PrescribedMedicationReviewItem: FC<{ medication: PrescribedMedicationDTO }> = (props) => { + const { medication } = props; + + return ( + <> + + {medication.name} + + {medication.instructions} + + ); +}; diff --git a/packages/telemed-ehr/app/src/telemed/features/appointment/ReviewTab/components/PrescribedMedicationsContainer.tsx b/packages/telemed-ehr/app/src/telemed/features/appointment/ReviewTab/components/PrescribedMedicationsContainer.tsx new file mode 100644 index 000000000..a4bacbd3b --- /dev/null +++ b/packages/telemed-ehr/app/src/telemed/features/appointment/ReviewTab/components/PrescribedMedicationsContainer.tsx @@ -0,0 +1,22 @@ +import { Box, Typography } from '@mui/material'; +import { FC } from 'react'; +import { getSelectors } from '../../../../../shared/store/getSelectors'; +import { useAppointmentStore } from '../../../../state'; +import { PrescribedMedicationReviewItem } from './PrescribedMedicationReviewItem'; + +export const PrescribedMedicationsContainer: FC = () => { + const { chartData } = getSelectors(useAppointmentStore, ['chartData']); + + return ( + + + Prescriptions + + + {chartData?.prescribedMedications?.map((med) => ( + + ))} + + + ); +}; diff --git a/packages/telemed-ehr/app/src/telemed/hooks/useZapEHRAPIClient.ts b/packages/telemed-ehr/app/src/telemed/hooks/useZapEHRAPIClient.ts index 84a5365bc..b741049d0 100644 --- a/packages/telemed-ehr/app/src/telemed/hooks/useZapEHRAPIClient.ts +++ b/packages/telemed-ehr/app/src/telemed/hooks/useZapEHRAPIClient.ts @@ -16,6 +16,7 @@ export const useZapEHRAPIClient = (): ReturnType | n deleteChartDataZambdaID: import.meta.env.VITE_APP_DELETE_CHART_DATA_ZAMBDA_ID, changeTelemedAppointmentStatusZambdaID: import.meta.env.VITE_APP_CHANGE_TELEMED_APPOINTMENT_STATUS_ZAMBDA_ID, getPatientInstructionsZambdaID: import.meta.env.VITE_APP_GET_PATIENT_INSTRUCTIONS_ZAMBDA_ID, + syncUserZambdaID: import.meta.env.VITE_APP_SYNC_USER_ZAMBDA_ID, savePatientInstructionZambdaID: import.meta.env.VITE_APP_SAVE_PATIENT_INSTRUCTION_ZAMBDA_ID, deletePatientInstructionZambdaID: import.meta.env.VITE_APP_DELETE_PATIENT_INSTRUCTION_ZAMBDA_ID, icdSearchZambdaId: import.meta.env.VITE_APP_ICD_SEARCH_ZAMBDA_ID, diff --git a/packages/telemed-ehr/app/src/telemed/state/appointment/appointment.queries.ts b/packages/telemed-ehr/app/src/telemed/state/appointment/appointment.queries.ts index bb6e05b92..cbb313ed4 100644 --- a/packages/telemed-ehr/app/src/telemed/state/appointment/appointment.queries.ts +++ b/packages/telemed-ehr/app/src/telemed/state/appointment/appointment.queries.ts @@ -1,5 +1,5 @@ import { useAuth0 } from '@auth0/auth0-react'; -import { Bundle, FhirResource } from 'fhir/r4'; +import { Bundle, FhirResource, Patient } from 'fhir/r4'; import { DateTime } from 'luxon'; import { useMutation, useQuery } from 'react-query'; import { @@ -16,6 +16,7 @@ import { useZapEHRAPIClient } from '../../hooks/useZapEHRAPIClient'; import { useAppointmentStore } from './appointment.store'; import useOttehrUser from '../../../hooks/useOttehrUser'; import { getSelectors } from '../../../shared/store/getSelectors'; +import { useAuthToken } from '../../../hooks/useAuthToken'; import { CHAT_REFETCH_INTERVAL } from '../../../constants'; import { extractPhotoUrlsFromAppointmentData } from '../../utils'; @@ -500,3 +501,32 @@ export const useDeletePatientInstruction = () => { }, }); }; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const useSyncERXPatient = () => { + const token = useAuthToken(); + + return useMutation( + ['sync-erx-patient'], + async (patient: Patient) => { + if (token) { + console.log(`Start syncing eRx patient ${patient.id}`); + const resp = await fetch(`${import.meta.env.VITE_APP_PROJECT_API_URL}/erx/sync-patient/${patient.id}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + method: 'POST', + }); + if (!resp.ok) { + throw { ...(await resp.json()), status: resp.status }; + } + console.log('Successfuly synced eRx patient'); + return (await resp.json()) as { photonPatientId: string }; + } + throw new Error('auth token is not defined'); + }, + { + retry: 2, + }, + ); +}; diff --git a/packages/telemed-ehr/zambdas/serverless.yml b/packages/telemed-ehr/zambdas/serverless.yml index 4cb674b5c..908e74713 100644 --- a/packages/telemed-ehr/zambdas/serverless.yml +++ b/packages/telemed-ehr/zambdas/serverless.yml @@ -102,6 +102,21 @@ functions: PROJECT_API: ${file(./.env/${self:provider.stage}.json):PROJECT_API} FHIR_API: ${file(./.env/${self:provider.stage}.json):FHIR_API} ENVIRONMENT: ${file(./.env/${self:provider.stage}.json):ENVIRONMENT} + sync-user: + handler: src/sync-user/index.index + events: + - http: + path: zambda/sync-user/execute + method: POST + timeout: 25 + environment: + AUTH0_ENDPOINT: ${file(./.env/${self:provider.stage}.json):AUTH0_ENDPOINT} + URGENT_CARE_AUTH0_CLIENT: ${file(./.env/${self:provider.stage}.json):URGENT_CARE_AUTH0_CLIENT} + URGENT_CARE_AUTH0_SECRET: ${file(./.env/${self:provider.stage}.json):URGENT_CARE_AUTH0_SECRET} + AUTH0_AUDIENCE: ${file(./.env/${self:provider.stage}.json):AUTH0_AUDIENCE} + PROJECT_API: ${file(./.env/${self:provider.stage}.json):PROJECT_API} + FHIR_API: ${file(./.env/${self:provider.stage}.json):FHIR_API} + ENVIRONMENT: ${file(./.env/${self:provider.stage}.json):ENVIRONMENT} deactivate-user: handler: src/deactivate-user/index.index events: diff --git a/packages/telemed-ehr/zambdas/src/change-telemed-appointment-status/index.ts b/packages/telemed-ehr/zambdas/src/change-telemed-appointment-status/index.ts index d8dedef63..142dd575e 100644 --- a/packages/telemed-ehr/zambdas/src/change-telemed-appointment-status/index.ts +++ b/packages/telemed-ehr/zambdas/src/change-telemed-appointment-status/index.ts @@ -49,13 +49,18 @@ export const performEffect = async ( ): Promise => { const { appointmentId, newStatus } = params; + console.log(`Changing status for appointment ${appointmentId} to ${newStatus}`); + const visitResources = await getVideoResources(fhirClient, appointmentId); if (!visitResources) { { + console.error('No visit resources found'); throw new Error(`Visit resources are not properly defined for appointment ${appointmentId}`); } } + console.log(`Visit resources: ${JSON.stringify(visitResources)}`); + if (visitResources.encounter?.subject?.reference === undefined) { throw new Error(`No subject reference defined for encoutner ${visitResources.encounter?.id}`); } diff --git a/packages/telemed-ehr/zambdas/src/sync-user/index.ts b/packages/telemed-ehr/zambdas/src/sync-user/index.ts new file mode 100644 index 000000000..9e9c3dbcd --- /dev/null +++ b/packages/telemed-ehr/zambdas/src/sync-user/index.ts @@ -0,0 +1,300 @@ +import { AppClient, FhirClient } from '@zapehr/sdk'; +import { APIGatewayProxyResult } from 'aws-lambda'; +import { ContactPoint, Identifier, Practitioner } from 'fhir/r4'; +import { + FHIR_IDENTIFIER_NPI, + PractitionerLicense, + Secrets, + SyncUserResponse, + allLicensesForPractitioner, + getPractitionerNPIIdentitifier, +} from 'ehr-utils'; +// import { SecretsKeys, getSecret } from '../shared'; +import { checkOrCreateM2MClientToken, createAppClient, createFhirClient } from '../shared/helpers'; +import { makeQualificationForPractitioner } from '../shared/practitioners'; +import { ZambdaInput } from '../types'; +import { validateRequestParameters } from './validateRequestParameters'; + +// Lifting up value to outside of the handler allows it to stay in memory across warm lambda invocations +let m2mtoken: string; + +export const index = async (input: ZambdaInput): Promise => { + try { + const { secrets } = validateRequestParameters(input); + console.log('Parameters: ' + JSON.stringify(input)); + + m2mtoken = await checkOrCreateM2MClientToken(m2mtoken, secrets); + const fhirClient = createFhirClient(m2mtoken, secrets); + const appClient = createAppClient(input.headers.Authorization.replace('Bearer ', ''), secrets); + + const response = await performEffect(appClient, fhirClient, secrets); + + return { + statusCode: 200, + body: JSON.stringify(response), + }; + } catch (error: any) { + console.log(JSON.stringify(error)); + return { + statusCode: 500, + body: JSON.stringify({ message: 'Error synchronizing practitioner with remote credentialing authority.' }), + }; + } +}; + +async function performEffect( + appClient: AppClient, + fhirClient: FhirClient, + secrets: Secrets | null, +): Promise { + const [remotePractitioner, localPractitioner] = await Promise.all([ + getRemotePractitionerAndCredentials(appClient, secrets), + getLocalEHRPractitioner(appClient, fhirClient), + ]); + if (!remotePractitioner) { + return { + message: 'Remote provider licenses and qualifications not found for current practitioner.', + updated: false, + }; + } + + // TODO: As it stands, practitioner will never get synchronized with the remote, as we have not + // implemented the logic to update the remote authority. + // This code will never get called + // + // We will also need to properly handle how data from remote authoirity is reconciled with + // the local EHR data that already exists. + // + let ehrPractitioner = { ...localPractitioner }; + console.log(`remotePractitioner: ${JSON.stringify(remotePractitioner)}`); + console.log(`localPractitioner: ${JSON.stringify(ehrPractitioner)}`); + ehrPractitioner = updatePractitionerName(ehrPractitioner, remotePractitioner); + ehrPractitioner.birthDate = remotePractitioner.date_of_birth; + ehrPractitioner = updatePractitionerPhone(ehrPractitioner, remotePractitioner); + ehrPractitioner = updatePractitionerPhoto(ehrPractitioner, remotePractitioner); + ehrPractitioner = updatePractitionerQualification(ehrPractitioner, remotePractitioner); + ehrPractitioner = updatePractitionerCredentials(ehrPractitioner, remotePractitioner); + ehrPractitioner = updatePractitionerNPI(ehrPractitioner, remotePractitioner); + const result = await updatePractitioner(fhirClient, ehrPractitioner); + console.log(`Practitioner updated successfully: ${JSON.stringify(result)}`); + if (result) + return { + message: 'Practitioner credentials have been synchronized with remote credentials authority successfully.', + updated: true, + }; + throw new Error('Failed updating practitioner...'); +} + +interface RemotePractitionerData { + id: string; + first_name?: string; + middle_name?: string; + last_name?: string; + date_of_birth?: string; + primary_phone?: string; + profession?: string; + user?: { + picture?: string; + }; + photoUrl?: string; + licenses?: PractitionerLicense[]; + npi?: string; +} + +async function getRemotePractitionerAndCredentials( + appClient: AppClient, + secrets: Secrets | null, +): Promise { + console.log('Preparing search parameters for remote practitioner'); + const myEhrUser = await appClient.getMe(); + const myEmail = myEhrUser.email.toLocaleLowerCase(); + console.log(`Preparing search for local ractitioner email: ${myEmail}`); + + const clinicianSearchResults: RemotePractitionerData[] = []; + + // TODO: this is where you could handle provider search results + // from a remote credentialing authority + // + // const searchResults: RemotePractitionerData[] = await searchRemoteCredentialsAuthority( + // `/api/v1/org/providers/?search=${myEmail}`, + // secrets, + // ); + + console.log(`Response: ${JSON.stringify(clinicianSearchResults)}`); + + if (clinicianSearchResults) { + const provider = clinicianSearchResults.find((provider: any) => provider.email === myEmail); + if (provider?.id) { + // TODO: this is where you could handle provider credentials and licenses + // from a remote credentialing authority + // + // const licensesResponse = await searchRemoteCredentialsAuthority( + // `api/v1/org/licenses/?provider=${provider.id}`, + // secrets, + // ); + // const licenses: PractitionerLicense[] = []; + // if (licensesResponse) { + // licensesResponse?.forEach((license: any) => { + // const code = license.certificate_type; + // const state = license.state; + // if (code && state) licenses.push({ code, state, active: true }); + // }); + // } + + return undefined; + // { + // ...provider, + // licenses, + // }; + } + } + return undefined; +} + +async function getLocalEHRPractitioner(appClient: AppClient, fhirClient: FhirClient): Promise { + const practitionerId = (await appClient.getMe()).profile.replace('Practitioner/', ''); + return await fhirClient.readResource({ + resourceType: 'Practitioner', + resourceId: practitionerId, + }); +} + +async function searchRemoteCredentialsAuthority(path: string, secrets: Secrets | null): Promise { + // const url = getSecret(SecretsKeys.REMOT_AUTHORITY_URL, secrets); + // const apiKey = getSecret(SecretsKeys.REMOT_AUTHORITY_API_KEY, secrets); + // return await fetch(`{url}{path}`, { + // method: 'GET', + // headers: { + // accept: 'application/json', + // 'x-api-key': apiKey, + // }, + // }) + // .then((res) => res.json()) + // .then((res) => res.results) + // .catch(console.error); +} + +function updatePractitionerName(localClinician: Practitioner, remoteClinician: RemotePractitionerData): Practitioner { + if (!(remoteClinician.first_name || remoteClinician.middle_name || remoteClinician.last_name)) return localClinician; + const firstName = remoteClinician.first_name; + const secondName = remoteClinician.middle_name; + const lastName = remoteClinician.last_name; + if (firstName || secondName || lastName) { + if (!localClinician.name) localClinician.name = [{}]; + const given = []; + if (!localClinician.name[0].given) { + if (firstName) given.push(firstName); + if (secondName) given.push(secondName); + if (given.length > 0) { + localClinician.name[0].given = given; + } + } + if (lastName) localClinician.name[0].family = lastName; + } + return localClinician; +} + +function updatePractitionerPhone(localClinician: Practitioner, remoteClinician: RemotePractitionerData): Practitioner { + if (!remoteClinician.primary_phone) return localClinician; + localClinician = findTelecomAndUpdateOrAddNew('phone', localClinician, remoteClinician.primary_phone); + localClinician = findTelecomAndUpdateOrAddNew('sms', localClinician, remoteClinician.primary_phone); + return localClinician; +} + +function findTelecomAndUpdateOrAddNew( + system: ContactPoint['system'], + practitioner: Practitioner, + newPhone: string, +): Practitioner { + const newPractitioner = { ...practitioner }; + const foundTelecomPhone = newPractitioner.telecom?.find((phone, id) => { + if (phone.system === system) { + if (newPractitioner.telecom) newPractitioner.telecom[id].value = newPhone; + return true; + } + return false; + }); + if (!foundTelecomPhone) { + const phoneRecord: ContactPoint = { + system: system, + value: newPhone, + }; + if (newPractitioner.telecom) { + newPractitioner.telecom.push(phoneRecord); + } else { + newPractitioner.telecom = [phoneRecord]; + } + } + return newPractitioner; +} + +function updatePractitionerPhoto(localClinician: Practitioner, remoteClinician: RemotePractitionerData): Practitioner { + if (!remoteClinician.photoUrl) return localClinician; + if (localClinician?.photo) { + if (localClinician.photo[0]) { + localClinician.photo[0] = { url: remoteClinician.photoUrl }; + } + } else { + localClinician.photo = [{ url: remoteClinician.photoUrl }]; + } + return localClinician; +} + +function updatePractitionerQualification( + localPractitioner: Practitioner, + remotePractitioner: RemotePractitionerData, +): Practitioner { + if (!remotePractitioner.licenses) return localPractitioner; + if (localPractitioner.qualification) { + const existedLicenses = allLicensesForPractitioner(localPractitioner); + const missingLicenses: PractitionerLicense[] = []; + remotePractitioner.licenses?.forEach((license) => { + if (!existedLicenses.find((existed) => existed.state === license.state && existed.code === license.code)) + missingLicenses.push(license); + }); + missingLicenses?.forEach((license) => { + localPractitioner.qualification?.push(makeQualificationForPractitioner(license)); + }); + } else { + localPractitioner.qualification = []; + remotePractitioner.licenses.forEach((license) => + localPractitioner.qualification!.push(makeQualificationForPractitioner(license)), + ); + } + return localPractitioner; +} + +function updatePractitionerNPI(localClinician: Practitioner, remoteClinician: RemotePractitionerData): Practitioner { + if (!remoteClinician.npi) return localClinician; + const identifier: Identifier = { + system: FHIR_IDENTIFIER_NPI, + value: `${remoteClinician.npi}`, + }; + + if (localClinician.identifier) { + const foundIdentifier = getPractitionerNPIIdentitifier(localClinician); + if (foundIdentifier && foundIdentifier.value !== identifier.value) { + foundIdentifier.value = identifier.value; + } else if (!foundIdentifier) localClinician.identifier.push(identifier); + } else { + localClinician.identifier = [identifier]; + } + return localClinician; +} + +function updatePractitionerCredentials( + localClinician: Practitioner, + remoteClinician: RemotePractitionerData, +): Practitioner { + if (!remoteClinician.profession) return localClinician; + if (!localClinician.name) localClinician.name = [{}]; + if (!localClinician.name[0].suffix?.includes(remoteClinician.profession)) { + if (!localClinician.name[0].suffix) localClinician.name[0].suffix = []; + localClinician.name[0].suffix.push(remoteClinician.profession); + } + return localClinician; +} + +async function updatePractitioner(fhirClient: FhirClient, practitioner: Practitioner): Promise { + return await fhirClient.updateResource(practitioner); +} diff --git a/packages/telemed-ehr/zambdas/src/sync-user/validateRequestParameters.ts b/packages/telemed-ehr/zambdas/src/sync-user/validateRequestParameters.ts new file mode 100644 index 000000000..fff74559c --- /dev/null +++ b/packages/telemed-ehr/zambdas/src/sync-user/validateRequestParameters.ts @@ -0,0 +1,12 @@ +import { ZambdaInput } from '../types'; + +export function validateRequestParameters(input: ZambdaInput): Pick { + console.group('validateRequestParameters'); + + console.groupEnd(); + console.debug('validateRequestParameters success'); + + return { + secrets: input.secrets, + }; +} diff --git a/packages/telemed-intake/app/index.html b/packages/telemed-intake/app/index.html index 9c82b14a7..69660b50a 100644 --- a/packages/telemed-intake/app/index.html +++ b/packages/telemed-intake/app/index.html @@ -2,7 +2,8 @@ - + + Ottehr Telemedicine diff --git a/packages/telemed-intake/app/ottehr-icon-256.png b/packages/telemed-intake/app/ottehr-icon-256.png new file mode 100644 index 000000000..dbf10a9bc Binary files /dev/null and b/packages/telemed-intake/app/ottehr-icon-256.png differ diff --git a/packages/telemed-intake/app/ottehr-icon.ico b/packages/telemed-intake/app/ottehr-icon.ico new file mode 100644 index 000000000..b82190225 Binary files /dev/null and b/packages/telemed-intake/app/ottehr-icon.ico differ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6f344499..ff54cb218 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -338,8 +338,8 @@ importers: specifier: ^5.0.20 version: 5.0.20(@emotion/react@11.13.0(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.13.0(@emotion/react@11.13.0(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(@mui/material@5.16.5(@emotion/react@11.13.0(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.13.0(@emotion/react@11.13.0(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@5.16.5(@emotion/react@11.13.0(@types/react@18.3.3)(react@18.3.1))(@emotion/styled@11.13.0(@emotion/react@11.13.0(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(react@18.3.1))(@types/react@18.3.3)(date-fns@2.30.0)(dayjs@1.11.12)(luxon@3.4.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@photonhealth/elements': - specifier: ^0.9.1-rc.1 - version: 0.9.2(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(solid-js@1.8.19)(ts-node@10.9.2(@types/node@18.19.42)(typescript@4.9.5)) + specifier: ^0.12.2 + version: 0.12.3(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(solid-js@1.8.19)(ts-node@10.9.2(@types/node@20.14.12)(typescript@4.9.5)) '@twilio/conversations': specifier: ^2.4.1 version: 2.5.0 @@ -361,6 +361,9 @@ importers: fast-json-patch: specifier: ^3.1.1 version: 3.1.1 + notistack: + specifier: ^3.0.1 + version: 3.0.1(csstype@3.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-chartjs-2: specifier: ^5.2.0 version: 5.2.0(chart.js@4.4.3)(react@18.3.1) @@ -2582,8 +2585,8 @@ packages: resolution: {integrity: sha512-SQa/6mJky63OkZw/gcA1N1SnprugqiP9VsTtE1VD2j4inqePlhD9xe4mU3WLlGtcbSBdlWbkuEpj2JexQ/cLDQ==} engines: {node: '>=16'} - '@photonhealth/elements@0.9.2': - resolution: {integrity: sha512-HHKi9ABYJEwhaGjmnw4xcEoNQ5ATOLTizyKp40MJtP8Pxuf7ZxTVzDENg2abDPrMmzyMptO18zyEsLgV6afB1g==} + '@photonhealth/elements@0.12.3': + resolution: {integrity: sha512-ASNHp/nVENOfGI6izFD+2YqzTH1biRusblIJTJTFLu8V2Hbw8CblUW6NJ142Tcrgp3FtuxhxGYTYUwbZp99xrg==} '@photonhealth/sdk@1.3.3': resolution: {integrity: sha512-YNEICmz+nvwfOnYJlHsEqNjiXhLbnS0utcROv1iyfTVmAPQQVaAmZNPzmxbBTb2egX56JU/DKdGeGjkpRfT2hg==} @@ -6176,6 +6179,11 @@ packages: globrex@0.1.2: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + goober@2.1.14: + resolution: {integrity: sha512-4UpC0NdGyAFqLNPnhCT2iHpza2q+RAY3GV85a/mRPdzyPQMsj0KmMMuetdIkzWRbJ+Hgau1EZztq8ImmiMGhsg==} + peerDependencies: + csstype: ^3.0.10 + gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} @@ -7882,6 +7890,13 @@ packages: resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} engines: {node: '>=10'} + notistack@3.0.1: + resolution: {integrity: sha512-ntVZXXgSQH5WYfyU+3HfcXuKaapzAJ8fBLQ/G618rn3yvSzEbnOB8ZSOwhX+dAORy/lw+GC2N061JA0+gYWTVA==} + engines: {node: '>=12.0.0', npm: '>=6.0.0'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + npm-registry-utilities@1.0.0: resolution: {integrity: sha512-9xYfSJy2IFQw1i6462EJzjChL9e65EfSo2Cw6kl0EFeDp05VvU+anrQk3Fc0d1MbVCq7rWIxeer89O9SUQ/uOg==} engines: {node: '>=12.0'} @@ -14258,17 +14273,17 @@ snapshots: dependencies: pako: 1.0.11 - '@photonhealth/components@0.0.0(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(ts-node@10.9.2(@types/node@18.19.42)(typescript@4.9.5))': + '@photonhealth/components@0.0.0(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(ts-node@10.9.2(@types/node@20.14.12)(typescript@4.9.5))': dependencies: '@photonhealth/sdk': 1.3.3(@types/react@18.3.3)(graphql@16.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@tailwindcss/typography': 0.5.13(tailwindcss@3.4.7(ts-node@10.9.2(@types/node@18.19.42)(typescript@4.9.5))) + '@tailwindcss/typography': 0.5.13(tailwindcss@3.4.7(ts-node@10.9.2(@types/node@20.14.12)(typescript@4.9.5))) clsx: 1.2.1 graphql: 16.9.0 graphql-tag: 2.12.6(graphql@16.9.0) jwt-decode: 3.1.2 solid-js: 1.8.19 superstruct: 1.0.4 - tailwindcss: 3.4.7(ts-node@10.9.2(@types/node@18.19.42)(typescript@4.9.5)) + tailwindcss: 3.4.7(ts-node@10.9.2(@types/node@20.14.12)(typescript@4.9.5)) transitivePeerDependencies: - '@types/react' - graphql-ws @@ -14277,9 +14292,9 @@ snapshots: - subscriptions-transport-ws - ts-node - '@photonhealth/elements@0.9.2(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(solid-js@1.8.19)(ts-node@10.9.2(@types/node@18.19.42)(typescript@4.9.5))': + '@photonhealth/elements@0.12.3(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(solid-js@1.8.19)(ts-node@10.9.2(@types/node@20.14.12)(typescript@4.9.5))': dependencies: - '@photonhealth/components': 0.0.0(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(ts-node@10.9.2(@types/node@18.19.42)(typescript@4.9.5)) + '@photonhealth/components': 0.0.0(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(ts-node@10.9.2(@types/node@20.14.12)(typescript@4.9.5)) '@photonhealth/sdk': 1.3.3(@types/react@18.3.3)(graphql@16.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@shoelace-style/shoelace': 2.15.1(@types/react@18.3.3) '@solid-primitives/scheduled': 1.4.3(solid-js@1.8.19) @@ -15246,13 +15261,13 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@tailwindcss/typography@0.5.13(tailwindcss@3.4.7(ts-node@10.9.2(@types/node@18.19.42)(typescript@4.9.5)))': + '@tailwindcss/typography@0.5.13(tailwindcss@3.4.7(ts-node@10.9.2(@types/node@20.14.12)(typescript@4.9.5)))': dependencies: lodash.castarray: 4.4.0 lodash.isplainobject: 4.0.6 lodash.merge: 4.6.2 postcss-selector-parser: 6.0.10 - tailwindcss: 3.4.7(ts-node@10.9.2(@types/node@18.19.42)(typescript@4.9.5)) + tailwindcss: 3.4.7(ts-node@10.9.2(@types/node@20.14.12)(typescript@4.9.5)) '@tanstack/solid-virtual@3.8.3(solid-js@1.8.19)': dependencies: @@ -16789,7 +16804,7 @@ snapshots: buffer@4.9.2: dependencies: base64-js: 1.5.1 - ieee754: 1.1.13 + ieee754: 1.2.1 isarray: 1.0.0 buffer@5.7.1: @@ -18873,6 +18888,10 @@ snapshots: globrex@0.1.2: {} + goober@2.1.14(csstype@3.1.3): + dependencies: + csstype: 3.1.3 + gopd@1.0.1: dependencies: get-intrinsic: 1.2.4 @@ -21221,6 +21240,15 @@ snapshots: normalize-url@6.1.0: {} + notistack@3.0.1(csstype@3.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + clsx: 1.2.1 + goober: 2.1.14(csstype@3.1.3) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - csstype + npm-registry-utilities@1.0.0: dependencies: ext: 1.7.0 @@ -21767,6 +21795,14 @@ snapshots: postcss: 8.4.40 ts-node: 10.9.2(@types/node@18.19.42)(typescript@4.9.5) + postcss-load-config@4.0.2(postcss@8.4.40)(ts-node@10.9.2(@types/node@20.14.12)(typescript@4.9.5)): + dependencies: + lilconfig: 3.1.2 + yaml: 2.5.0 + optionalDependencies: + postcss: 8.4.40 + ts-node: 10.9.2(@types/node@20.14.12)(typescript@4.9.5) + postcss-loader@6.2.1(postcss@8.4.40)(webpack@5.93.0(esbuild@0.18.20)): dependencies: cosmiconfig: 7.1.0 @@ -23633,6 +23669,33 @@ snapshots: transitivePeerDependencies: - ts-node + tailwindcss@3.4.7(ts-node@10.9.2(@types/node@20.14.12)(typescript@4.9.5)): + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.2 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.6 + lilconfig: 2.1.0 + micromatch: 4.0.7 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.0.1 + postcss: 8.4.40 + postcss-import: 15.1.0(postcss@8.4.40) + postcss-js: 4.0.1(postcss@8.4.40) + postcss-load-config: 4.0.2(postcss@8.4.40)(ts-node@10.9.2(@types/node@20.14.12)(typescript@4.9.5)) + postcss-nested: 6.2.0(postcss@8.4.40) + postcss-selector-parser: 6.1.1 + resolve: 1.22.8 + sucrase: 3.35.0 + transitivePeerDependencies: + - ts-node + tapable@1.1.3: {} tapable@2.2.1: {} @@ -23827,6 +23890,25 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + ts-node@10.9.2(@types/node@20.14.12)(typescript@4.9.5): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.11 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 20.14.12 + acorn: 8.12.1 + acorn-walk: 8.3.3 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 4.9.5 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + optional: true + tsconfck@3.1.1(typescript@4.9.5): optionalDependencies: typescript: 4.9.5