// src/utils/auditEventRow.ts // // - WHAT AN ACCESS-LOG ROW SAYS. // // THE DEFECT THIS EXISTS TO END. Every row on the Practice Access Log rendered the same four things: // // RESTful Operation | Read | Aug 31, 2026 | Success // // No resource, no actor, no subject, no time of day - so 5000 rows were indistinguishable and a practice // could not tell which Delete was the one it was looking for. Measured 2026-08-31 at Great River. // // AND THE RECORD ALREADY HELD ALL OF IT. `buildEntityArray` writes `entity.what.reference` as // `ResourceType/id`, `entity.name` and `entity.description`; the agent carries `name` (the username) and // `who`; and `recorded` is a full ISO instant of which only the date was ever shown. Nothing had to be // captured differently. This is the renderer, and that is why the fix is a pure function rather than a // change to what we audit. // // EVERY FIELD IS OPTIONAL AND EVERY ONE IS ANSWERED WITH `undefined` RATHER THAN A PLACEHOLDER. A row // that cannot say who acted must say nothing in that slot, not "Unknown" - an access log is read as // evidence, and inventing a confident-looking word for an absent field is the one thing it must never do. import type { AuditEvent } from 'fhir/r4'; export interface AuditEventRow { /** The resource acted on, as `ResourceType/id`, or just the type when there is no id. */ what?: string; /** Just the resource type, for filtering and for a shorter label. */ resourceType?: string; /** A human description the writer supplied, e.g. "Assistant disclosed 2 medications entries". */ description?: string; /** Who did it - the username where we have one, otherwise the agent reference. */ who?: string; /** The agent as a reference, e.g. `Practitioner/id`, for a caller that can name it. */ actor?: string; /** Whose record it concerns, as `Patient/id`, when that is a different resource from `what`. */ subject?: string; } const PATIENT_REFERENCE = /^Patient\/(.+)$/; // A sign-in username that is an opaque account id names nobody a reader knows. const OPAQUE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /** * The parts of an audit entry a reader needs to tell it from the one above it. * * Deliberately does no formatting and no truncation: the caller owns how a row looks, and a pure * function that also decides presentation cannot be tested for either job on its own. */ export const describeAuditEvent = (auditEvent: AuditEvent): AuditEventRow => { const entities = auditEvent.entity ?? []; // THE FIRST ENTITY IS THE RESOURCE ACTED ON. `buildEntityArray` appends the subject patient AFTER // it, so taking the first is not an assumption about ordering we hope holds - it is the order that // function writes, and the subject is identified below by its shape rather than by its position. const acted = entities[0]; const what = acted?.what?.reference; const resourceType = acted?.type?.code ?? (what ? what.split('/')[0] : undefined); // The subject is a Patient entity that is NOT the resource acted on. When somebody reads a patient's // own record those are the same thing, and repeating it in two columns says nothing twice. const subjectEntity = entities.find((entity) => { const reference = entity.what?.reference; return Boolean(reference && PATIENT_REFERENCE.test(reference) && reference !== what); }); const agent = (auditEvent.agent ?? []).find((candidate) => candidate.requestor) ?? auditEvent.agent?.[0]; return { what: what || undefined, resourceType: resourceType || undefined, description: acted?.description || acted?.name || undefined, // `name` is the username and is what a human recognises. The reference is the fallback, and it is // an id - worth showing, because an id somebody can search for beats an empty column. who: (agent?.name && !OPAQUE_ID.test(agent.name) ? agent.name : undefined) || agent?.who?.display || agent?.who?.reference || agent?.name || undefined, actor: agent?.who?.reference || undefined, subject: subjectEntity?.what?.reference || undefined, }; };