Skip to content

Commit

Permalink
feat/add-veradigm (#4)
Browse files Browse the repository at this point in the history
  • Loading branch information
cfu288 committed Mar 31, 2023
1 parent 246ecc9 commit 546b0a1
Show file tree
Hide file tree
Showing 52 changed files with 166,548 additions and 205 deletions.
2 changes: 2 additions & 0 deletions apps/api/src/app/root.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import { StaticModule } from './static/static.module';
import { LoginProxyModule } from './proxy/proxy.module';
import { CernerModule } from './cerner/cerner.module';
import { EpicModule } from './epic/epic.module';
import { VeradigmModule } from './veradigm/veradigm.module';

const imports: ModuleMetadata['imports'] = [
StaticModule,
LoginProxyModule,
CernerModule,
EpicModule,
VeradigmModule,
];

if (checkIfOnPatientConfigured()) {
Expand Down
19 changes: 19 additions & 0 deletions apps/api/src/app/veradigm/veradigm.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Controller, Get, Logger, Query, Res } from '@nestjs/common';
import { Response } from 'express';
import { VeradigmService } from './veradigm.service';

@Controller('v1/veradigm')
export class VeradigmController {
constructor(private readonly veradigmService: VeradigmService) {}

@Get('tenants')
async getData(@Res() response: Response, @Query('query') query) {
try {
const data = await this.veradigmService.queryTenants(query);
response.json(data);
} catch (e) {
Logger.log(e);
response.status(500).send({ message: 'There was an error' });
}
}
}
9 changes: 9 additions & 0 deletions apps/api/src/app/veradigm/veradigm.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { VeradigmService } from './veradigm.service';
import { VeradigmController } from './veradigm.controller';

@Module({
controllers: [VeradigmController],
providers: [VeradigmService],
})
export class VeradigmModule {}
82 changes: 82 additions & 0 deletions apps/api/src/app/veradigm/veradigm.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Injectable } from '@nestjs/common';
import { VeradigmDSTU2TenantEndpoints, DSTU2Endpoint } from '@mere/veradigm';

@Injectable()
export class VeradigmService {
private readonly items = VeradigmDSTU2TenantEndpoints;

async queryTenants(query: string): Promise<DSTU2Endpoint[]> {
return filteredItemsWithQuery(this.items, query);
}
}

function filteredItemsWithQuery(items: DSTU2Endpoint[], query: string) {
if (query === '' || query === undefined) {
return items
.filter(
(item) =>
!!item.name?.trim() &&
!!item.authorize?.trim() &&
!!item.token?.trim()
)
.sort((x, y) => (x.name > y.name ? 1 : -1))
.slice(0, 100);
}
return items
.filter(
(item) =>
!!item.name?.trim() && !!item.authorize?.trim() && !!item.token?.trim()
)
.map((item) => {
// Match against each token, take highest score
const vals = item.name
.split(' ')
.map((token) => stringSimilarity(token, query));
const rating = Math.max(...vals);
return { rating, item };
})
.filter((item) => item.rating > 0.05)
.sort((a, b) => b.rating - a.rating)
.slice(0, 50)
.map((item) => item.item);
}

/**
* Compares the similarity between two strings using an n-gram comparison method.
* The grams default to length 2.
* @param str1 The first string to compare.
* @param str2 The second string to compare.
* @param gramSize The size of the grams. Defaults to length 2.
*/
export function stringSimilarity(str1: string, str2: string, gramSize = 2) {
if (!str1?.length || !str2?.length) {
return 0.0;
}

//Order the strings by length so the order they're passed in doesn't matter
//and so the smaller string's ngrams are always the ones in the set
const s1 = str1.length < str2.length ? str1 : str2;
const s2 = str1.length < str2.length ? str2 : str1;

const pairs1 = getNGrams(s1, gramSize);
const pairs2 = getNGrams(s2, gramSize);
const set = new Set<string>(pairs1);

const total = pairs2.length;
let hits = 0;
for (const item of pairs2) {
if (set.delete(item)) {
hits++;
}
}
return hits / total;
}

function getNGrams(s: string, len: number) {
s = ' '.repeat(len - 1) + s.toLowerCase() + ' '.repeat(len - 1);
const v = new Array(s.length - len + 1);
for (let i = 0; i < v.length; i++) {
v[i] = s.slice(i, i + len);
}
return v;
}
1 change: 1 addition & 0 deletions apps/web/src/Routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export enum Routes {
OnPatientCallback = '/onpatient/callback',
EpicCallback = '/epic/callback',
CernerCallback = '/cerner/callback',
VeradigmCallback = '/veradigm/callback',
}
28 changes: 17 additions & 11 deletions apps/web/src/components/ModalHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,31 @@
import { Dialog } from '@headlessui/react';
import { XMarkIcon } from '@heroicons/react/20/solid';
import { ReactNode } from 'react';

export function ModalHeader({
title,
setClose: setOpen,
subtitle,
setClose,
}: {
title: string;
subtitle?: string | ReactNode;
setClose: (x: boolean) => void;
}) {
return (
<Dialog.Title>
<div className="flex justify-between">
<p className="p-4 text-xl font-bold">{title}</p>
<button
type="button"
className="rounded-3xl bg-white text-gray-500 hover:text-gray-700 focus:text-gray-700 focus:outline-none focus:ring-0 "
onClick={() => setOpen(false)}
>
<span className="sr-only">Close</span>
<XMarkIcon className="mr-4 h-8 w-8" aria-hidden="true" />
</button>
<div className="flex w-full flex-col p-4 pb-2">
<div className="flex justify-between">
<p className="text-xl font-bold">{title}</p>
<button
type="button"
className="rounded-3xl bg-white text-gray-500 hover:text-gray-700 focus:text-gray-700 focus:outline-none focus:ring-0 "
onClick={() => setClose(false)}
>
<span className="sr-only">Close</span>
<XMarkIcon className="ml-4 h-8 w-8" aria-hidden="true" />
</button>
</div>
<p>{subtitle}</p>
</div>
</Dialog.Title>
);
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/components/TabWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { TabButton } from './TabButton';
import { useUser } from './providers/UserProvider';
import { TimelineTab } from '../pages/TimelineTab';
import CernerRedirect from '../pages/CernerRedirect';
import VeradigmRedirect from '../pages/VeradigmRedirect';

export function TabWrapper() {
const user = useUser();
Expand All @@ -35,6 +36,10 @@ export function TabWrapper() {
/>
<Route path={AppRoutes.EpicCallback} element={<EpicRedirect />} />
<Route path={AppRoutes.CernerCallback} element={<CernerRedirect />} />
<Route
path={AppRoutes.VeradigmCallback}
element={<VeradigmRedirect />}
/>
<Route path="*" element={<Navigate to={AppRoutes.Timeline} />} />
</Routes>
</div>
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/components/connection/ButtonLoadingSpinner.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
export function ButtonLoadingSpinner() {
export function ButtonLoadingSpinner({
height = 'h-4',
width = 'w-4',
}: {
height?: string;
width?: string;
}) {
return (
<div role="status">
<svg
aria-hidden="true"
className="fill-primary-700 mr-2 h-4 w-4 animate-spin text-gray-200 dark:text-gray-600"
className={`fill-primary-700 mr-2 animate-spin text-gray-200 dark:text-gray-600 ${height} ${width}`}
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
Expand Down
13 changes: 11 additions & 2 deletions apps/web/src/components/connection/ConnectionCard.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ConnectionDocument } from '../../models/connection-document/ConnectionDocument.type';
import {
ConnectionDocument,
ConnectionSources,
} from '../../models/connection-document/ConnectionDocument.type';
import { useRxDb } from '../providers/RxDbProvider';
import onpatientLogo from '../../img/onpatient_logo.jpeg';
import epicLogo from '../../img/MyChartByEpic.png';
import cernerLogo from '../../img/cerner-logo.png';
import allscriptsConnectLogo from '../../img/allscripts-logo.png';
import { differenceInDays, format, parseISO } from 'date-fns';
import { RxDocument } from 'rxdb';
import { useNotificationDispatch } from '../providers/NotificationProvider';
Expand All @@ -16,7 +20,7 @@ import {
useSyncJobDispatchContext,
} from '../providers/SyncJobProvider';

function getImage(logo: 'onpatient' | 'epic' | 'cerner') {
function getImage(logo: ConnectionSources) {
switch (logo) {
case 'onpatient': {
return onpatientLogo;
Expand All @@ -27,6 +31,9 @@ function getImage(logo: 'onpatient' | 'epic' | 'cerner') {
case 'cerner': {
return cernerLogo;
}
case 'veradigm': {
return allscriptsConnectLogo;
}
default: {
return undefined;
}
Expand Down Expand Up @@ -131,6 +138,8 @@ export function ConnectionCard({
? `MyChart - ${item.get('name')}`
: item.get('source') === 'cerner'
? `Cerner - ${item.get('name')}`
: item.get('source') === 'veradigm'
? `Veradigm - ${item.get('name')}`
: item.get('name')}
</h3>
</div>
Expand Down
118 changes: 118 additions & 0 deletions apps/web/src/components/connection/VeradigmSelectModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { memo, useEffect, useState } from 'react';
import { Combobox } from '@headlessui/react';
import { MagnifyingGlassIcon } from '@heroicons/react/20/solid';
import { ExclamationCircleIcon } from '@heroicons/react/24/outline';
import { useDebounce } from '@react-hook/debounce';
import { Modal } from '../Modal';
import { ModalHeader } from '../ModalHeader';
import { SelectOption } from '../../pages/ConnectionTab';
import { DSTU2Endpoint } from '@mere/cerner';
import { CernerSelectModelResultItem } from './CernerSelectModalItem';
import { useNotificationDispatch } from '../providers/NotificationProvider';

export function VeradigmSelectModal({
open,
setOpen,
onClick,
}: {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
onClick: (
base: string & Location,
auth: string & Location,
token: string & Location,
name: string,
id: string
) => void;
}) {
const [query, setQuery] = useDebounce('', 150),
[items, setItems] = useState<DSTU2Endpoint[]>([]),
notifyDispatch = useNotificationDispatch();

useEffect(() => {
const abortController = new AbortController();

fetch(`/api/v1/veradigm/tenants?` + new URLSearchParams({ query }), {
signal: abortController.signal,
})
.then((x) => x.json())
.then((x) => setItems(x))
.catch(() => {
notifyDispatch({
type: 'set_notification',
message: `Unable to search for health systems`,
variant: 'error',
});
});

return () => {
abortController.abort();
};
}, [notifyDispatch, query]);

return (
<Modal
open={open}
setOpen={setOpen}
afterLeave={() => setQuery('')}
overflowHidden
>
<ModalHeader
title={'Select your Veradigm health system to log in'}
setClose={() => setOpen((x) => !x)}
/>
<Combobox
onChange={(s: SelectOption) => {
onClick(s.baseUrl, s.authUrl, s.tokenUrl, s.name, s.id);
setOpen(false);
}}
>
<div className="relative px-4">
<MagnifyingGlassIcon
className="pointer-events-none absolute top-3.5 left-8 h-5 w-5 text-gray-400"
aria-hidden="true"
/>
<Combobox.Input
className="focus:ring-primary-700 h-12 w-full divide-y-2 rounded-xl border-0 bg-gray-50 bg-transparent pl-11 pr-4 text-gray-800 placeholder-gray-400 hover:border-gray-200 focus:ring-2 sm:text-sm"
placeholder="Search for your health system"
onChange={(event) => setQuery(event.target.value)}
autoFocus={true}
/>
</div>
{items.length > 0 && (
<Combobox.Options
static
className="max-h-full scroll-py-3 overflow-y-scroll p-3 sm:max-h-96"
>
{items.map((item) => (
<MemoizedCernerResultItem
key={item.id}
id={item.id}
name={item.name}
baseUrl={item.url}
tokenUrl={item.token}
authUrl={item.authorize}
/>
))}
</Combobox.Options>
)}

{query !== '' && items.length === 0 && (
<div className="py-14 px-6 text-center text-sm sm:px-14">
<ExclamationCircleIcon
type="outline"
name="exclamation-circle"
className="mx-auto h-6 w-6 text-gray-400"
/>
<p className="mt-4 font-semibold text-gray-900">No results found</p>
<p className="mt-2 text-gray-500">
No health system found for this search term. Please try again.
</p>
</div>
)}
</Combobox>
</Modal>
);
}

export const MemoizedCernerResultItem = memo(CernerSelectModelResultItem);
Loading

0 comments on commit 546b0a1

Please sign in to comment.