Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
98 changes: 96 additions & 2 deletions packages/esm-audit-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,16 @@ The dashboard is a three step drill-down. Each step keeps its position in the pa
so a particular encounter's audit trail can be linked to and the browser's back button walks back
up the drill-down.

1. **Patient search** — find the patient by name or identifier
(`GET /ws/rest/v1/patient?q=…`).
1. **Find the record** — three tabs:
- **Patients** — by name or identifier (`GET /ws/rest/v1/patient?q=…`).
- **Users** — by username, system id or the person's name (`GET /ws/rest/v1/user?q=…`), then the
encounters whose observations that account recorded or deleted. This is the one view that
genuinely answers "what did this account change", and it depends on pihcore — see below.
- **Providers** — by name or provider identifier (`GET /ws/rest/v1/provider?q=…`), then the
encounters that provider is recorded on. See the caveat below on what that does and does not
mean.

Every tab drills into the same encounter audit trail at step 3.
2. **Encounter list** — every encounter recorded for that patient, most recent first
(`GET /ws/rest/v1/encounter?patient=…&order=desc`, paged by the server through
`useOpenmrsPagination`), narrowable by encounter type and by the date the encounter happened. The type dropdown offers only the types this patient's own encounters
Expand Down Expand Up @@ -72,6 +80,92 @@ come from once deleted encounters are shown — otherwise a type used only by a
would be missing from the dropdown. Both hooks build the same url, so SWR serves the second one
from cache rather than reading it twice.

### The Users tab depends on pihcore

The core REST API cannot search observations by the account that touched them: neither the obs
resource, the observation search handler, core's own `ObsSearchCriteria` nor FHIR's
`ObservationSearchParams` has a creator or voidedBy field. Those columns can be read off an
observation but not searched on.

`openmrs-module-pihcore` adds the endpoint that can:

```
GET /ws/rest/v1/pihcore/obsaudit?createdBy=<user>&voidedBy=<user>&startDate=<date>&endDate=<date>
```

`createdBy` and `voidedBy` accept a user uuid, username or system id, and results come back paged
in the standard obs representation, most recent audit action first. `startDate` and `endDate` bound
when the change was made rather than when the observation was taken, and are what the tab's
**Changed between** picker sends — as bare calendar dates, since the endpoint reads a date-only
`endDate` as the whole of that day. Narrowing the range narrows the trail on the server, so it is
also the way to make a long-serving account's history quick to read.

The tab's **Encounter type** filter is different in kind: the endpoint takes no encounter type, so
it is applied after the merge. It offers every type in the system rather than only those the trail
holds, because lazy discovery means the types in a trail are unknown until all of it has been read.
Filling a page of a rare type therefore reads further than filling a page of any type; when the
scan gives up before the trail runs out, the view says so and suggests narrowing the dates. **The Users tab is inert without
a pihcore version that provides it**, and it requires the `App: coreapps.systemAdministration`
privilege that endpoint is gated on.

Two consequences shape the tab. The endpoint's two filters narrow rather than widen, so "touched"
takes two searches — one for each parameter. And it answers in observations while an auditor reads a
record in encounters, so the observations have to be collapsed into one row per encounter.

The collapse is done lazily rather than by reading the whole trail. Both searches return
observations by audit date descending, which means the first time an encounter appears in the merged
stream is its most recent change — so encounters come out of the merge already in the order the list
wants, and reading can stop as soon as enough of them are certain to fill the page on screen. Paging
forward reads further; paging back costs nothing, because what has been read is kept.

"Certain" is the subtle part, and `src/audit/user-encounters.ts` is where it lives. An observation
not yet read can only be older than the last row read from its search, so an encounter is safe to
show only once its most recent change is later than both searches' positions; a search that has not
been read at all constrains everything, which is why it is tracked separately from one that is
exhausted. Getting this wrong would make the first page reshuffle as the second was read.

The per-encounter counts cannot come from that scan, since an encounter's other observations may sit
far further down the trail. They are read per row instead — `obs?encounter=…&includeAll=true`,
attributed by creator and voiding user — so they are exact, and cost one small request per row
actually on screen.

Because the number of encounters an account has touched is unknown until its whole trail has been
read, which is the very thing being avoided, the list has no total to page against: it shows the
range on screen with previous and next buttons rather than a page count.

### Caveat: searching by provider is not searching by who edited

The Providers tab lists the encounters a provider is **recorded on**, which is not the same as the
ones that account entered or changed — an encounter someone entered without being a provider on it
will not appear. The view says as much and names the entering user per row, and the Users tab is
what answers the other question.

Both go through pihcore, since core cannot search encounters by either. `EncounterSearchCriteria`
carries a providers field but no webservices search handler exposes it, the free-text encounter
search only matches patient name and identifier when no patient is given, and there is no `doGetAll`
to enumerate and filter client-side:

```
GET /ws/rest/v1/pihcore/encounteraudit?provider=<uuid or identifier>
GET /ws/rest/v1/pihcore/encounteraudit?createdBy=<user>&changedBy=<user>&voidedBy=<user>
```

Filters narrow, at least one user or provider is required, and results are paged in the standard
encounter representation — including `auditInfo`, which is why one paged request serves a whole page
with nothing read per row. `encounterType`, `startDate` and `endDate` narrow it further;
`encounterType` accepts a uuid or a type name, and the dates bound whatever the search is about: the
named audit action's column where there is one, and the encounter's own datetime for a provider
search.

The Providers tab's encounter type and date filters are those parameters, so the page count and
totals stay right — which is also why its type dropdown offers every type in the system rather than
only those on screen. The Patients tab shares the same filter bar but supplies the patient's own
types, since there they can be known.

This replaced an earlier route through FHIR (`Encounter?participant=`), which worked but needed a
`_tag` to avoid a paging crash in fhir2's combined encounter-and-visit bundle provider, and a
follow-up REST read per row because FHIR carries no `auditInfo`.

### Caveat: what the activity view costs

Encounters carry their own `auditInfo`, so who created, changed or deleted each one comes free with
Expand Down
58 changes: 58 additions & 0 deletions packages/esm-audit-app/src/audit/audit-search.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Tab, TabList, TabPanel, TabPanels, Tabs } from '@carbon/react';
import PatientSearch from './patient-search.component';
import ProviderSearch from './provider-search.component';
import UserSearch from './user-search.component';
import styles from './audit.scss';

export type AuditSearchTab = 'patients' | 'users' | 'providers';

export const auditSearchTabs: Array<AuditSearchTab> = ['patients', 'users', 'providers'];

interface AuditSearchProps {
tab: AuditSearchTab;
onSelectTab(tab: AuditSearchTab): void;
onSelectPatient(patientUuid: string): void;
onSelectUser(userUuid: string): void;
onSelectProvider(providerUuid: string): void;
}

/**
* Where the audit trail starts: find the record to audit — by the patient it belongs to, by the
* user who changed it, or by a provider recorded on it.
*/
export default function AuditSearch({
tab,
onSelectTab,
onSelectPatient,
onSelectUser,
onSelectProvider,
}: AuditSearchProps) {
const { t } = useTranslation();

return (
<div className={styles.section}>
<Tabs
onChange={({ selectedIndex }) => onSelectTab(auditSearchTabs[selectedIndex])}
selectedIndex={Math.max(0, auditSearchTabs.indexOf(tab))}>
<TabList aria-label={t('searchBy', 'Search by')}>
<Tab>{t('patients', 'Patients')}</Tab>
<Tab>{t('users', 'Users')}</Tab>
<Tab>{t('providers', 'Providers')}</Tab>
</TabList>
<TabPanels>
<TabPanel>
<PatientSearch onSelectPatient={onSelectPatient} />
</TabPanel>
<TabPanel>
<UserSearch onSelectUser={onSelectUser} />
</TabPanel>
<TabPanel>
<ProviderSearch onSelectProvider={onSelectProvider} />
</TabPanel>
</TabPanels>
</Tabs>
</div>
);
}
79 changes: 63 additions & 16 deletions packages/esm-audit-app/src/audit/audit.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,42 +2,65 @@ import React, { useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { PageHeader, PageHeaderContent, PatientSearchPictogram } from '@openmrs/esm-framework';
import AuditSearch, { type AuditSearchTab, auditSearchTabs } from './audit-search.component';
import EncounterAudit from './encounter-audit.component';
import PatientRecord, { type PatientRecordView, patientRecordViews } from './patient-record.component';
import PatientSearch from './patient-search.component';
import ProviderEncounters from './provider-encounters.component';
import UserEncounters from './user-encounters.component';
import styles from './audit.scss';

interface AuditLocation {
patient?: string;
user?: string;
provider?: string;
encounter?: string;
view?: PatientRecordView;
search?: AuditSearchTab;
}

const locationParams: Array<keyof AuditLocation> = ['patient', 'user', 'provider', 'encounter', 'view', 'search'];

/**
* The audit trail is a three step drill-down — find a patient, pick one of their encounters, then
* read what was entered, changed and deleted on it. The current step is held in the query string
* so that a particular encounter's audit trail can be linked to and so that the browser's back
* button walks back up the drill-down.
* The audit trail is a drill-down — find the record, pick one of its encounters, then read what
* was entered, changed and deleted on it. Where it is drilled down to is held in the query
* string, so a particular encounter's audit trail can be linked to and so that the browser's back
* button walks back up.
*/
export default function Audit() {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
const patientUuid = searchParams.get('patient');
const userUuid = searchParams.get('user');
const providerUuid = searchParams.get('provider');
const encounterUuid = searchParams.get('encounter');
const view = searchParams.get('view');
const patientRecordView: PatientRecordView = patientRecordViews.includes(view as PatientRecordView)
? (view as PatientRecordView)
: 'encounters';

const oneOf = <T,>(allowed: Array<T>, value: string | null, fallback: T): T =>
allowed.includes(value as T) ? (value as T) : fallback;
const patientRecordView = oneOf(patientRecordViews, searchParams.get('view'), 'encounters');
const searchTab = oneOf(auditSearchTabs, searchParams.get('search'), 'patients');

const goTo = useCallback(
(next: { patient?: string; encounter?: string; view?: PatientRecordView }) => {
(next: AuditLocation) => {
const params = new URLSearchParams(searchParams);
params.delete('patient');
params.delete('encounter');
params.delete('view');
locationParams.forEach((param) => params.delete(param));
if (next.patient) {
params.set('patient', next.patient);
}
if (next.user) {
params.set('user', next.user);
}
if (next.provider) {
params.set('provider', next.provider);
}
if (next.encounter) {
params.set('encounter', next.encounter);
}
if (next.view && next.view !== 'encounters') {
params.set('view', next.view);
}
if (next.search && next.search !== 'patients') {
params.set('search', next.search);
}
setSearchParams(params);
},
[searchParams, setSearchParams],
Expand All @@ -51,18 +74,42 @@ export default function Audit() {
{encounterUuid ? (
<EncounterAudit
encounterUuid={encounterUuid}
onBackToEncounters={(uuid) => goTo({ patient: uuid ?? patientUuid, view: patientRecordView })}
onBackToEncounters={(uuid) =>
userUuid
? goTo({ user: userUuid })
: providerUuid
? goTo({ provider: providerUuid })
: goTo({ patient: uuid ?? patientUuid, view: patientRecordView })
}
/>
) : userUuid ? (
<UserEncounters
onBackToSearch={() => goTo({ search: 'users' })}
onSelectEncounter={(uuid) => goTo({ user: userUuid, encounter: uuid })}
userUuid={userUuid}
/>
) : providerUuid ? (
<ProviderEncounters
onBackToSearch={() => goTo({ search: 'providers' })}
onSelectEncounter={(uuid) => goTo({ provider: providerUuid, encounter: uuid })}
providerUuid={providerUuid}
/>
) : patientUuid ? (
<PatientRecord
onBackToSearch={() => goTo({})}
onBackToSearch={() => goTo({ search: 'patients' })}
onSelectEncounter={(uuid) => goTo({ patient: patientUuid, encounter: uuid, view: patientRecordView })}
onSelectView={(nextView) => goTo({ patient: patientUuid, view: nextView })}
patientUuid={patientUuid}
view={patientRecordView}
/>
) : (
<PatientSearch onSelectPatient={(uuid) => goTo({ patient: uuid })} />
<AuditSearch
onSelectPatient={(uuid) => goTo({ patient: uuid })}
onSelectProvider={(uuid) => goTo({ provider: uuid })}
onSelectTab={(tab) => goTo({ search: tab })}
onSelectUser={(uuid) => goTo({ user: uuid })}
tab={searchTab}
/>
)}
</div>
);
Expand Down
Loading
Loading