// A packet assignment sent and not yet settled, kept in this browser under its viewer, practice and patient so a reload // asks again with the same operation and request instead of making another. Only the request the server compares is // kept, never a token, a title or an answer; the server's manifest is the record, and this is only how to ask again. // It is released only on the server's own answer naming this operation's manifest as made or as ended for good. import { HttpError } from '../../services/HttpService'; import type { PacketAssignmentRequest } from '../../services/QuestionnairePacketService'; export interface PacketAssignmentOwner { author: string; route: string; practice: string } export interface HeldPacketAssignment { operationId: string; route: string; firstAt: number; request: PacketAssignmentRequest } const PREFIX = 'packet-assignment:v1:'; const EVENT = 'packet-assignment-recovery-changed'; const LIMIT = 20; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const FHIR_ID = /^[A-Za-z0-9\-.]{1,64}$/; const DATE = /^\d{4}-\d{2}-\d{2}$/; const PRIORITIES = ['routine', 'urgent', 'asap', 'stat']; const ENDED = ['rejected', 'cancelled', 'entered-in-error']; // A manual assignment's published manifest stays requested; anything else is not proof that it was made. const PUBLISHED = 'requested'; const digest = async (value: string): Promise => Array.from(new Uint8Array( await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)), )).map((byte) => byte.toString(16).padStart(2, '0')).join(''); export async function packetAssignmentOwner(viewer: string | null | undefined, practice: string | undefined, patient: string | undefined): Promise { if (!viewer || !practice || !patient) throw new Error('A current viewer, practice and patient are required.'); return { author: await digest(JSON.stringify(['packet-assignment-author', viewer])), route: await digest(JSON.stringify(['packet-assignment/v1', viewer, practice, patient])), practice, }; } /** The manifest id the server gives this practice's operation, so an answer can be proved to be about it. */ export const ownedAssignmentId = async (practice: string, operationId: string): Promise => `qpa-${(await digest(['manual', practice, operationId].join('|'))).slice(0, 40)}`; const canonical = (request: PacketAssignmentRequest): PacketAssignmentRequest => ({ packetId: request.packetId, priority: request.priority, ...(request.dueDate !== undefined ? { dueDate: request.dueDate } : {}), ...(request.appointmentId !== undefined ? { appointmentId: request.appointmentId } : {}), }); const isRequest = (value: unknown): value is PacketAssignmentRequest => { const request = value as Record | null; return Boolean(request && typeof request === 'object' && !Array.isArray(request) && Object.keys(request).every((key) => ['packetId', 'priority', 'dueDate', 'appointmentId'].includes(key)) && typeof request.packetId === 'string' && FHIR_ID.test(request.packetId) && typeof request.priority === 'string' && PRIORITIES.includes(request.priority) && (request.dueDate === undefined || (typeof request.dueDate === 'string' && DATE.test(request.dueDate))) && (request.appointmentId === undefined || (typeof request.appointmentId === 'string' && FHIR_ID.test(request.appointmentId))) && !(request.dueDate !== undefined && request.appointmentId !== undefined)); }; function read(owner: PacketAssignmentOwner): HeldPacketAssignment[] { const raw = localStorage.getItem(PREFIX + owner.author); if (raw === null) return []; const list: unknown = JSON.parse(raw); const valid = Array.isArray(list) && list.length <= LIMIT && list.every((entry: HeldPacketAssignment) => entry && Object.keys(entry).every((key) => ['operationId', 'route', 'firstAt', 'request'].includes(key)) && typeof entry.operationId === 'string' && UUID.test(entry.operationId) && typeof entry.route === 'string' && /^[0-9a-f]{64}$/.test(entry.route) && Number.isFinite(entry.firstAt) && isRequest(entry.request)) && new Set(list.map((entry: HeldPacketAssignment) => entry.route)).size === list.length; if (!valid) throw new Error('Packet assignment recovery could not be read.'); return list; } function write(owner: PacketAssignmentOwner, list: HeldPacketAssignment[]): void { const raw = JSON.stringify(list); localStorage.setItem(PREFIX + owner.author, raw); if (localStorage.getItem(PREFIX + owner.author) !== raw) throw new Error('Packet assignment recovery could not be kept.'); window.dispatchEvent(new CustomEvent(EVENT, { detail: owner.author })); } const exclusive = (owner: PacketAssignmentOwner, work: () => T): Promise => { if (!navigator.locks?.request) return Promise.reject(new Error('Packet assignment recovery coordination is unavailable.')); return navigator.locks.request(PREFIX + owner.author, work) as Promise; }; /** What this route holds now. An unreadable record throws: it is never taken for none. */ export function heldPacketAssignment(owner: PacketAssignmentOwner): HeldPacketAssignment | undefined { return read(owner).find((entry) => entry.route === owner.route); } /** * Keeps a new operation for `request` before anything is sent, or hands back the one this route already holds, which * is then the assignment to ask about. Nothing is reserved when it cannot be kept and read back. `created` learns the * new operation inside the lock, before this window's own change notification fires. */ export function reservePacketAssignment( owner: PacketAssignmentOwner, request: PacketAssignmentRequest, admit: () => void, created?: (entry: HeldPacketAssignment) => void, ): Promise<{ entry: HeldPacketAssignment; fresh: boolean }> { return exclusive(owner, () => { admit(); const list = read(owner); const held = list.find((entry) => entry.route === owner.route); if (held) return { entry: held, fresh: false }; if (list.length >= LIMIT) throw new Error('Check earlier packet assignments before starting another.'); const entry = { operationId: crypto.randomUUID(), route: owner.route, firstAt: Date.now(), request: canonical(request) }; created?.(entry); write(owner, [...list, entry]); return { entry, fresh: true }; }); } /** * Whether this exact operation and request are still held at the moment of asking. A release after this check is still * safe to send into: the server answers the same operation from the same stored manifest. */ export function admitPacketAssignmentSend(owner: PacketAssignmentOwner, operationId: string, request: PacketAssignmentRequest): Promise { return exclusive(owner, () => { const held = read(owner).find((entry) => entry.route === owner.route); return Boolean(held && held.operationId === operationId && JSON.stringify(held.request) === JSON.stringify(canonical(request))); }); } /** Removes this exact operation, and only while the attempt asking still belongs, checked once the lock is held. */ export function releasePacketAssignment(owner: PacketAssignmentOwner, operationId: string, admit: () => void): Promise { return exclusive(owner, () => { admit(); const list = read(owner); const rest = list.filter((entry) => !(entry.route === owner.route && entry.operationId === operationId)); if (rest.length !== list.length) write(owner, rest); }); } export function onPacketAssignmentRecoveryChange(owner: PacketAssignmentOwner, changed: () => void): () => void { const local = (event: Event) => { if ((event as CustomEvent).detail === owner.author) changed(); }; const remote = (event: StorageEvent) => { if (event.key === null || event.key === PREFIX + owner.author) changed(); }; window.addEventListener(EVENT, local); window.addEventListener('storage', remote); return () => { window.removeEventListener(EVENT, local); window.removeEventListener('storage', remote); }; } type Named = { assignment?: { id?: unknown; status?: unknown } } | undefined; /** A success settles the operation only when it names this operation's own manifest, published. */ export const successSettles = async (practice: string, operationId: string, answer: Named): Promise => typeof answer?.assignment?.id === 'string' && answer.assignment.status === PUBLISHED && answer.assignment.id === await ownedAssignmentId(practice, operationId); /** * A refusal settles the operation only when the server names this operation's own manifest as ended for good, or as * published and damaged, which this browser stops asking about. A refusal code alone proves nothing. */ export const refusalSettles = async (practice: string, operationId: string, failure: unknown): Promise => { const body = failure instanceof HttpError && failure.body && typeof failure.body === 'object' ? failure.body as { code?: unknown } & Named : undefined; const id = body?.assignment?.id; const status = body?.assignment?.status; if (typeof body?.code !== 'string' || typeof id !== 'string' || typeof status !== 'string') return false; if (id !== await ownedAssignmentId(practice, operationId)) return false; return ENDED.includes(status) || (body.code === 'ASSIGNMENT_DAMAGED' && status === PUBLISHED); };