import { useState, useEffect, useCallback, useMemo, useRef, useSyncExternalStore } from 'react'; import { useSearchParams } from 'react-router-dom'; import { drillPeriodFrom } from '../../../utils/reportDrillPeriod'; import { useFhirPractitioners } from '../../../hooks/useFhirPractitioners'; import { formatFhirHumanName } from '../../../utils/fhirHumanName.tsx'; import { Item, ItemGroup } from "../../../components/ui/item-group"; import { Card, CardBody, CardHeader } from '../../../components/ui/wp-card'; import { DismissibleAlert } from '../../../components/ui/dismissible-alert'; import { Heading } from '../../../components/ui/heading'; import { Flex, FlexItem, FlexBlock } from '../../../components/ui/flex'; import { Text } from '../../../components/ui/text'; import { Button } from '../../../components/ui/button'; import { AuditEvent } from 'fhir/r4'; import { useAuditEventServiceContext } from '../../../services'; import { AppSpinner } from '../../../components'; import { describeAuditEvent } from '../../../utils/auditEventRow'; import { nextAuditPage, type AuditActionCode } from '../../../utils/auditEventQuery'; import { forThisViewer, ownedRead } from '../../../utils/ownedRead'; import { onViewerChange, viewerEpoch } from '../../../utils/writeThroughHold'; // How long a page of the log may take before the screen says it could not be loaded. const AUDIT_READ_DEADLINE_MS = 20_000; /* * - A PRACTICE COULD NOT FIND ANYTHING IN ITS OWN ACCESS LOG. * * Measured 2026-08-31 at Great River as an Admin. Every row rendered the same four things - * "RESTful Operation | Read | Aug 31, 2026 | Success" - and the page fetched the ENTIRE history before * drawing anything: 45 sequential requests, ~20 seconds on a blank screen, 5000 rows in the DOM, of which * every single one was from today. 4987 of them were Reads. * * So the log could answer neither question it exists for. It could not say who read a patient's chart, * because no row named a resource, an actor or a subject; and it could not reach yesterday at all, * because a day of reads fills the window and older entries were never requested. * * THREE CHANGES, AND EACH ONE IS USELESS WITHOUT THE OTHERS: * the rows say what happened describeAuditEvent, pure and tested * the narrowing runs on the SERVER auditEventSearchParams, so a day that is not today is reachable * one page at a time, with more on request * * AND THE ROW IS NO LONGER A LINK. It navigated to /dashboard/audit-events/{id}, which is not a declared * route - App.tsx registers audit-events and nothing under it - so every click fell through to the * dashboard redirect and landed on the Organization page. A control that silently takes you somewhere * unrelated is worse than no control, and with the row now carrying the resource, the actor and the * subject there is nothing a detail view would add. fetchAuditEvent stays in the service for whoever * builds one. */ const ACTIONS: { code: AuditActionCode; label: string }[] = [ { code: 'C', label: 'Create' }, { code: 'R', label: 'Read' }, { code: 'U', label: 'Update' }, { code: 'D', label: 'Delete' }, { code: 'E', label: 'Execute' }, ]; const ACTION_LABELS: Record = Object.fromEntries( ACTIONS.map(({ code, label }) => [code, label]), ); const OUTCOMES: Record = { 0: 'Success', 4: 'Minor Failure', 8: 'Serious Failure', 12: 'Major Failure', }; /** * The full instant, because the time of day is what separates one of today's entries from another. * The old row showed the date alone, so 5000 rows all read "Aug 31, 2026". */ const whenOf = (auditEvent: AuditEvent): string => { if (!auditEvent.recorded) return ''; const at = new Date(auditEvent.recorded); return Number.isNaN(at.getTime()) ? '' : at.toLocaleString(); }; const AuditEventsPage = () => { const { fetchAuditEvents } = useAuditEventServiceContext(); // Staff act as their Practitioner; the log names them rather than printing an id. const { practitionersList } = useFhirPractitioners(); const staffNames = useMemo(() => new Map((practitionersList ?? []) .filter((item) => item.practitioner.id && item.practitioner.name?.length) .map((item) => ['Practitioner/' + item.practitioner.id, formatFhirHumanName(item.practitioner.name)])), [practitionersList]); // One log per viewer and practice; a change of either is a new opening. const viewer = useSyncExternalStore(onViewerChange, viewerEpoch); const [shownFor, setShownFor] = useState(viewer); const current = shownFor === viewer; const [loadingState, setLoading] = useState(false); const [loadingMoreState, setLoadingMore] = useState(false); const [failedState, setFailed] = useState(false); const [eventsState, setAuditEvents] = useState([]); const [cursorState, setCursor] = useState(); // Until the new opening has reset them, nothing read for the last viewer or practice is drawn. const loading = loadingState || !current; const loadingMore = current && loadingMoreState; const failed = current && failedState; const auditEvents = current ? eventsState : []; const cursor = current ? cursorState : undefined; // Bumped by every first page, so an older page asked for under a replaced one is dropped. const asked = useRef(0); const reads = useRef(new Set()); // A report row opens the log on its period; after that the reader's own dates rule. const [searchParams] = useSearchParams(); const [drill] = useState(() => drillPeriodFrom(searchParams)); const opened = { from: drill?.from ?? '', to: drill?.to ?? '' }; // Days entered after a drill are read on the same clock the report used. const zone = drill?.zone; // What the reader has ASKED FOR, held separately from what has been fetched, so a half-written date // does not re-query on every keystroke. The query runs when Apply is pressed. const [fromInput, setFromInput] = useState(opened.from); const [toInput, setToInput] = useState(opened.to); const [actionInput, setActionInput] = useState(''); const [applied, setApplied] = useState<{ from: string; to: string; action: AuditActionCode | '' }>( { ...opened, action: '' }, ); const load = useCallback(async ( narrowing: { from: string; to: string; action: AuditActionCode | '' }, page?: string, ) => { if (!page) { reads.current.forEach((read) => read.abort()); reads.current.clear(); asked.current += 1; setLoadingMore(false); setCursor(undefined); setAuditEvents([]); } const question = asked.current; const owns = forThisViewer(() => asked.current === question); if (page) { setLoadingMore(true); } else { setLoading(true); } setFailed(false); try { const bundle = await ownedRead((request) => fetchAuditEvents({ from: narrowing.from || undefined, to: narrowing.to || undefined, action: narrowing.action || undefined, zone, page, }, request), { deadlineMs: AUDIT_READ_DEADLINE_MS, owns, track: reads.current }); if (!owns()) return; const rows = (bundle.entry || []).map((entry) => entry.resource as AuditEvent); setAuditEvents((previous) => (page ? [...previous, ...rows] : rows)); setCursor(nextAuditPage(bundle)); } catch (error) { if (!owns()) return; // A FAILED READ IS NOT "THERE WAS NOTHING", and on this screen above all others the // difference matters: "we looked and nobody touched it" is an answer somebody may rely on. console.error('Failed to load audit events:', error); setFailed(true); if (!page) { setAuditEvents([]); } } finally { if (owns()) { if (page) { setLoadingMore(false); } else { setLoading(false); } } } }, [fetchAuditEvents]); // A new viewer or practice reads the narrowing on screen again; the drill is the first narrowing. const appliedRef = useRef(applied); appliedRef.current = applied; useEffect(() => { setShownFor(viewer); void load(appliedRef.current); return () => { asked.current += 1; reads.current.forEach((read) => read.abort()); reads.current.clear(); }; }, [load, viewer]); const apply = () => { const narrowing = { from: fromInput, to: toInput, action: actionInput }; setApplied(narrowing); load(narrowing); }; const isNarrowed = Boolean(applied.from || applied.to || applied.action); return ( Practice Access Log
{failed && ( The access log could not be loaded, so this is not a record of what happened. Try again in a moment. )} {!loading && !failed && auditEvents.length === 0 && ( {isNarrowed ? 'Nothing was recorded matching that.' : 'No activity has been recorded for this practice yet.'} )} {loading ? ( ) : ( <> {auditEvents.length !== 0 && ( {auditEvents.map((auditEvent: AuditEvent) => { const row = describeAuditEvent(auditEvent); return ( {ACTION_LABELS[auditEvent.action || ''] || auditEvent.action || ''} {row.what ? ' ' + row.what : ''} {(row.actor && staffNames.get(row.actor)) || row.who || ''} {row.subject || ''} {whenOf(auditEvent)} {auditEvent.outcome !== undefined ? OUTCOMES[auditEvent.outcome] || 'Outcome ' + auditEvent.outcome : ''} {row.description && ( // A description can be one unbroken search string; it wraps rather than widening the page. {row.description} )} ); })} )} {cursor && (
)} )}
); }; export default AuditEventsPage;