// src/utils/auditEventQuery.ts // // - NARROWING THE ACCESS LOG AT THE SERVER, WHICH IS THE ONLY PLACE IT CAN WORK. // // THE DEFECT THIS EXISTS TO END. The page fetched EVERY page of the audit history before rendering // anything - 45 sequential requests, ~20 seconds on a blank screen, 5000 rows in the DOM - and then // showed the lot. Measured 2026-08-31: all 5000 rendered rows carried ONE date, today, because a single // day of reads fills the window. Yesterday's entries were not filtered out; they were never asked for. // // SO A CLIENT-SIDE FILTER WOULD HAVE BEEN WORSE THAN NONE. It would give a working-looking date control // over rows that all come from the same day, and reaching an older entry would still be impossible - a // control that cannot fail visibly is the trap this card names. The narrowing has to be in the query. // // `date` IS THE SEARCH PARAMETER FOR `recorded`, and it is a standard AuditEvent parameter rather than // anything we defined. Two bounds are sent as two `date` values, which is an AND in FHIR - `ge` on the // start of the day and `le` on the end of it. // // NO `_sort`. Not because ordering does not matter but because nobody has measured that HealthLake // accepts it on this resource, and a rejected parameter takes the whole screen down rather than // degrading. The natural order is what the page has always rendered. import { dayStartIn } from './reportDrillPeriod'; import { addCalendarDays } from './practiceTimezone'; /** One of the FHIR audit action codes, as the page's filter offers them. */ export type AuditActionCode = 'C' | 'R' | 'U' | 'D' | 'E'; export interface AuditEventQuery { /** Inclusive lower bound, as `YYYY-MM-DD`. */ from?: string; /** Inclusive upper bound, as `YYYY-MM-DD`. */ to?: string; /** The clock the days are counted on; without one the server reads them as UTC days. */ zone?: string; action?: AuditActionCode; /** FHIR AuditEvent `patient` — forwarded as-is when present. */ patient?: string; /** How many rows to ask for. The page asks for one screenful, not the whole history. */ count: number; /** The opaque cursor from the previous page's `next` link, when following one. */ page?: string; } const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/; /** * The query string for one page of the access log. * * A bound that is not a plain `YYYY-MM-DD` is DROPPED rather than sent. A half-typed date in a date * field is a normal intermediate state, and sending `2026-08-` would have the server reject the request * and blank the screen while somebody is still typing. */ export const auditEventSearchParams = (query: AuditEventQuery): string => { const params = new URLSearchParams(); params.append('_count', String(query.count)); if (query.from && ISO_DAY.test(query.from)) { params.append('date', query.zone ? 'ge' + dayStartIn(query.from, query.zone) : `ge${query.from}`); } if (query.to && ISO_DAY.test(query.to)) { // The whole of the closing day, not the instant it starts. `le2026-08-30` against a `recorded` // of `2026-08-30T14:22:11Z` is a comparison a reader would expect to include, and FHIR date // precision makes it so - but being explicit here is what stops the off-by-one-day report. params.append('date', query.zone ? 'lt' + dayStartIn(addCalendarDays(query.to, 1), query.zone) : `le${query.to}`); } if (query.action) { params.append('action', query.action); } if (query.patient) { params.append('patient', query.patient); } if (query.page) { params.set('page', query.page); } return params.toString(); }; /** * The cursor for the next page, or `undefined` when this is the last one. * * THE `next` URL POINTS AT THE DATASTORE, WHICH THE BROWSER CANNOT CALL. Only the opaque `page` token * inside it is ours to reuse, against our own gateway - the same reading `fetchAllBundlePages` does. * Do not "simplify" this to following the URL. */ export const nextAuditPage = (bundle: { link?: { relation?: string; url?: string }[] }): string | undefined => { const next = bundle.link?.find((link) => link.relation === 'next')?.url; const token = next?.match(/page=([^&]*)/)?.[1]; return token ? decodeURIComponent(token) : undefined; };