import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { Button } from '../../../../../components/ui/button'; import { Loader2, Eye, PencilLine, LogIn, Building2 } from 'lucide-react'; import { usePatientAccessLogService } from '../../../../../services/PatientAccessLogService.tsx'; import type { PatientAccessLogEvent, PatientAccessLogResponse } from '../../../../../services/PatientAccessLogService.tsx'; import { collapseHistory } from '../../../../../utils/history/collapseHistory'; import type { HistoryRow } from '../../../../../utils/history/collapseHistory'; import { isRefusedRead } from '../../../../../utils/fetchFailureMessage'; import { forThisViewer, ownedRead } from '../../../../../utils/ownedRead'; import { onViewerChange, viewerEpoch } from '../../../../../utils/writeThroughHold'; // How long the log may take before the screen says it could not be loaded. const ACCESS_LOG_READ_DEADLINE_MS = 20_000; // - "who has been in this chart", for a practitioner with a care relationship to this patient. // // Reuses `collapseHistory` READ-ONLY: individual reads are noise, so reads by one actor inside // a session window collapse into a single "accessed this record" row, while every CHANGE stays its own // row. That module is claude-2's - import it, never refactor it. // // The three audit surfaces stay distinct: the org-wide Admin log is every patient, the // patient's own My History is cross-org, and this one is ONE patient, gated on a care // relationship. Do not merge any two of them. const formatTime = (iso: string | undefined): string => iso ? new Date(iso).toLocaleString() : 'an unknown time'; const describeRow = (row: HistoryRow): string => { // RENDER THE SERVER'S WORDING VERBATIM for session rows. "Signed out" (the user explicitly ended // the session) and "Session ended" (a TTL expired; nobody ever saw them leave) are different claims, // and only the server knows which is true. Composing our own sentence here would collapse or overstate // the distinction. if (row.kind === 'session') { return row.entityName ?? 'session activity'; } // The same argument, one row kind over. "Visit started", "Visit ended" and "Visit reopened" are // three different claims about a real-world event, and only the server knows which is true - a // reopen in particular is the one the reader most needs to be able to tell apart, because a // mis-click and a patient who genuinely came back produce otherwise identical rows. Composing // "changed the patient's Encounter" here collapsed all three into the least useful of them. if (row.kind === 'visit' || row.kind === 'conference') { return row.entityName ?? 'visit activity'; } // A reminder is a notice about the visit, not an edit of it; the server's words say whether it went. if (row.kind === 'reminder') { return row.entityName ?? 'appointment reminder'; } // A helper's act-as session end, as the server classified it; never an edit of the record. if (row.kind === 'proxySession') { if (row.accessSession === 'start') return 'started acting for the patient'; if (row.accessSession === 'timeout') return 'session acting for the patient timed out'; return 'stopped acting for the patient'; } return row.kind === 'access' ? 'accessed this record' : `changed the patient's ${row.detail[0]?.resourceType ?? 'record'}`; }; // The cross-org marker rides on the individual events, not the collapsed row, so it is read off the // first detail entry. Only CHANGE rows can carry it - the server removes cross-org ACCESS rows before // they ever reach the client. const isFromAnotherOrganization = (row: HistoryRow): boolean => Boolean((row.detail[0] as PatientAccessLogEvent | undefined)?.fromAnotherOrganization); // Where the row was recorded, as the record says: another practice, no practice (a patient's own session), or a // practice that was not recorded. Unknown is never called another practice; a row from this practice says nothing. const practiceLine = (row: HistoryRow): string | undefined => { if (isFromAnotherOrganization(row)) return 'Recorded at another practice'; const association = (row.detail[0] as PatientAccessLogEvent | undefined)?.organizationAssociation; if (association === 'none') return 'Recorded under no practice'; if (association === 'unknown') return 'Practice not recorded'; return undefined; }; const reopenReasonOf = (row: HistoryRow): string | undefined => row.kind === 'visit' ? (row.detail[0] as PatientAccessLogEvent | undefined)?.reopenReason : undefined; interface PatientAccessLogProps { patientId: string; } type Reach = Pick & { capped: boolean }; const reachOf = (response: PatientAccessLogResponse): Reach => ({ capped: Boolean(response.capped), windowDays: response.windowDays, readBackTo: response.readBackTo, older: response.older, reachedStart: response.reachedStart, stalled: response.stalled, }); // Every event read so far, once each: grouping is done over all of them, so a session split across pages reads as one. const withOlder = (shown: PatientAccessLogEvent[], older: PatientAccessLogEvent[]): PatientAccessLogEvent[] => { const ids = new Set(shown.map((event) => event.id).filter(Boolean)); return [...shown, ...older.filter((event) => !event.id || !ids.has(event.id))]; }; const PatientAccessLog = ({ patientId }: PatientAccessLogProps) => { const { fetchPatientAccessLog } = usePatientAccessLogService(); const [events, setEvents] = useState([]); const rows: HistoryRow[] = useMemo(() => collapseHistory(events), [events]); // What the page is a page OF. See the note on the truncation line below for why the screen has to // say this rather than let the reader infer it from the rows. const [reach, setReach] = useState({ capped: false }); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState<'refused' | 'failed' | undefined>(); // An older page being read, or one that failed: the rows already shown stay either way. const [olderRead, setOlderRead] = useState<'idle' | 'loading' | 'failed'>('idle'); // The opening's own ownership check and reads, so an older page belongs to the opening that asked for it. const current = useRef<{ owns: () => boolean; reads: Set } | null>(null); // One log per viewer, practice and patient; reopening a patient after another is a new opening. const viewer = useSyncExternalStore(onViewerChange, viewerEpoch); const scope = viewer + '|' + patientId; const opening = useRef({ scope, n: 0 }); if (opening.current.scope !== scope) opening.current = { scope, n: opening.current.n + 1 }; // The opening the rows were set for; until the new opening has reset them, nothing of the last is drawn. const [shownFor, setShownFor] = useState(opening.current.n); useEffect(() => { const n = opening.current.n; let live = true; const owns = forThisViewer(() => live && opening.current.n === n); const reads = new Set(); setShownFor(n); setEvents([]); setReach({ capped: false }); setOlderRead('idle'); current.current = { owns, reads }; const load = async () => { setLoading(true); setLoadError(undefined); try { const response = await ownedRead((request) => fetchPatientAccessLog(patientId, request), { deadlineMs: ACCESS_LOG_READ_DEADLINE_MS, owns, track: reads }); if (!owns()) return; setEvents(response.events ?? []); setReach(reachOf(response)); } catch (error) { if (!owns()) return; console.error('Failed to load the patient access log:', error); // A failure must NEVER render as an empty log. "No activity on this record" is the one // thing this surface cannot say wrongly - it would read as "nobody has been in this chart". setLoadError(isRefusedRead(error) ? 'refused' : 'failed'); } finally { if (owns()) setLoading(false); } }; void load(); return () => { live = false; reads.forEach((read) => read.abort()); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [scope]); const loadOlder = async () => { const reading = current.current; const older = reach.older; if (!reading || !older || olderRead === 'loading') return; setOlderRead('loading'); try { const response = await ownedRead((request) => fetchPatientAccessLog(patientId, request, older), { deadlineMs: ACCESS_LOG_READ_DEADLINE_MS, owns: reading.owns, track: reading.reads }); if (!reading.owns()) return; setEvents((shown) => withOlder(shown, response.events ?? [])); setReach((was) => ({ ...reachOf(response), readBackTo: response.readBackTo ?? was.readBackTo })); setOlderRead('idle'); } catch (error) { if (!reading.owns()) return; console.error('Failed to load older access log activity:', error); setOlderRead('failed'); } }; if (loading || shownFor !== opening.current.n) { return (

); } // A refusal is the server's answer, not a fault: it names itself and offers no retry. if (loadError === 'refused') { return (

The access log for this patient is not available to you.

); } if (loadError) { return (

The access log could not be loaded. Please try again.

); } // An empty first page that the server says continues is not an empty log. if (rows.length === 0 && !reach.capped) { return (

No recorded activity on this record in the last {reach.windowDays ?? 90} days.

); } return (

Who accessed and who changed this patient's record. Accesses are shown for your organization; changes are shown from every practice that holds this record.

{/* THE SCREEN SAYS WHAT IT IS A PAGE OF, rather than letting the rows imply it. WALKED, 2026-09-13: a document was archived at 6:21 PM and this log rendered four rows reaching back to 10:48 PM, with nothing to say the search had come back full or that rows had been dropped behind it. The honest reading of that screen was that nothing else existed - on the surface a practice would use to answer who touched a record, in the one situation where anybody asks. Where the server hands back an older-page cursor the remainder is one button away; where it cannot (an older server, or one moment holding more entries than it reads at once) the screen still says "there is more, and not here" rather than letting the end read as the start. */} {reach.readBackTo && (

Showing activity back to {formatTime(reach.readBackTo)}.

)} {reach.capped && !reach.older && (

Only the latest activity is shown here. Older entries exist but cannot be opened from this screen.

)}
{rows.map((row, index) => (
{row.kind === 'session' ? (
))}
{reach.reachedStart && (

That is all recorded activity in the last {reach.windowDays ?? 90} days.

)} {olderRead === 'failed' && (

Older activity could not be loaded. The entries above are unchanged.

)} {reach.older && ( )}
); }; export default PatientAccessLog;