import type { MyHistoryEvent } from './collapseHistory'; // ONE READING OF A MY-HISTORY ANSWER, shared by every screen that asks for one. // // A success that is not the shape this client reads is not an empty feed: a missing or malformed // `events` became `events ?? []`, and on this feed that reads as "nobody touched your record". // Unreadable is an error the caller shows with a retry. Omitted optional fields stay valid. export interface MyHistoryResponse { events: MyHistoryEvent[]; // Whether the server has more to give, and where to resume. Both optional: a server that // predates them says nothing, and an absent value must not be read as "the record ends here". // `olderThan` is the oldest row the server LOOKED AT, which is not the oldest row it returned. mayHaveOlder?: boolean; olderThan?: string; // Sent only for the default window: the floor it applied, to hand back as `before` for earlier activity. earlierThan?: string; } export class MyHistoryUnreadableError extends Error { constructor(readonly reason: string) { super(`MY_HISTORY_UNREADABLE: ${reason}`); this.name = 'MyHistoryUnreadableError'; } } export const isMyHistoryUnreadable = (error: unknown): error is MyHistoryUnreadableError => error instanceof MyHistoryUnreadableError || (error as { name?: unknown } | null)?.name === 'MyHistoryUnreadableError'; const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/; const isInstant = (value: unknown): value is string => typeof value === 'string' && ISO_INSTANT.test(value) && Number.isFinite(Date.parse(value)); const OPTIONAL_STRINGS = [ 'action', 'resourceType', 'resourceId', 'actorReference', 'organizationName', 'organizationReference', 'outcome', 'entityName', 'sessionId', 'disclosure', 'versionId', ] as const; const OPTIONAL_BOOLEANS = ['expandable', 'humanInitiated'] as const; const ASSOCIATIONS = new Set(['organization', 'none', 'unknown']); const SESSION_ENDS = new Set(['start', 'exit', 'timeout']); const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); const supplied = (row: Record, key: string): boolean => row[key] !== undefined; const rowProblem = (row: unknown): string | undefined => { if (!isRecord(row)) return 'a row is not an object'; if (typeof row.id !== 'string' || row.id.length === 0) return 'a row has no id'; if (!isInstant(row.recorded)) return 'a row has no readable time'; if (typeof row.actorName !== 'string') return 'a row has no actor name'; if (OPTIONAL_STRINGS.some((key) => supplied(row, key) && typeof row[key] !== 'string')) return 'a row field is not text'; if (OPTIONAL_BOOLEANS.some((key) => supplied(row, key) && typeof row[key] !== 'boolean')) return 'a row flag is not true or false'; if (supplied(row, 'organizationAssociation') && !ASSOCIATIONS.has(row.organizationAssociation as string)) return 'a row practice association is unknown'; if (supplied(row, 'accessSession') && !SESSION_ENDS.has(row.accessSession as string)) return 'a row session end is unknown'; if (supplied(row, 'durationMs') && !(typeof row.durationMs === 'number' && Number.isFinite(row.durationMs))) return 'a row duration is not a number'; return undefined; }; /** Reads one my-history answer, or throws MyHistoryUnreadableError. Never yields an invented empty feed. */ export const parseMyHistoryResponse = (body: unknown): MyHistoryResponse => { if (!isRecord(body)) throw new MyHistoryUnreadableError('the answer is not an object'); if (!Array.isArray(body.events)) throw new MyHistoryUnreadableError('the answer has no list of events'); for (const row of body.events) { const problem = rowProblem(row); if (problem) throw new MyHistoryUnreadableError(problem); } if (supplied(body, 'mayHaveOlder') && typeof body.mayHaveOlder !== 'boolean') throw new MyHistoryUnreadableError('mayHaveOlder is not true or false'); if (supplied(body, 'olderThan') && !isInstant(body.olderThan)) throw new MyHistoryUnreadableError('olderThan is not a time'); if (supplied(body, 'earlierThan') && !isInstant(body.earlierThan)) throw new MyHistoryUnreadableError('earlierThan is not a time'); const response: MyHistoryResponse = { events: body.events as MyHistoryEvent[] }; if (body.mayHaveOlder !== undefined) response.mayHaveOlder = body.mayHaveOlder as boolean; if (body.olderThan !== undefined) response.olderThan = body.olderThan as string; if (body.earlierThan !== undefined) response.earlierThan = body.earlierThan as string; return response; };