// Only the id, its state and non-secret binding references are kept on the device; never the appointment or a token. export const BOOKING_OPERATION_HEADER = 'X-Booking-Operation'; /** Where in the product a booking is made; one open operation per opening, patient, practice and signed-in person. */ export type BookingOpening = 'patient-booking' | 'time-window-request' | 'desk-booking'; export interface BookingOperationBinding { /** The signed-in account (its Cognito subject). */ account: string; /** The person the account is acting for, when it is switched to one. */ actingAs?: string; practice: string; patient: string; opening: BookingOpening; } export type BookingOperationState = 'sending' | 'unconfirmed'; export interface BookingOperationEntry { version: 1; key: string; operationId: string; state: BookingOperationState; startedAt: number; } /** * The persistence seam: IndexedDB in a browser, memory in tests. Every change is one atomic step on one key, so two * tabs can never both open an operation for one binding, nor settle the other's. Each resolves once persisted. */ export interface BookingOperationStorage { read(key: string): Promise; /** The entry already open under its key, or this one, kept. */ getOrCreate(entry: BookingOperationEntry): Promise<{ entry: BookingOperationEntry; created: boolean }>; /** Replaces the entry only while its key still holds this operation. */ updateIf(next: BookingOperationEntry): Promise; /** Removes the entry only while its key still holds this operation. */ removeIf(key: string, operationId: string): Promise; } /** This device could not keep the booking's operation id, so the booking was not sent. */ export const BOOKING_STORAGE_UNAVAILABLE = 'This device could not save the booking before sending it, so nothing was booked. ' + 'Allow this site to store data (a private window may block it) and try again.'; export class BookingStorageUnavailableError extends Error { constructor() { super(BOOKING_STORAGE_UNAVAILABLE); this.name = 'BookingStorageUnavailableError'; } } export const bookingOperationKey = (binding: BookingOperationBinding): string => ['v1', binding.account, binding.actingAs ?? '', binding.practice, binding.patient, binding.opening].join('|'); const DB_NAME = 'akashic-booking-operations'; const DB_VERSION = 1; const STORE = 'operations'; const openDatabase = (): Promise => new Promise((resolve, reject) => { if (typeof indexedDB === 'undefined') { reject(new Error('IndexedDB unavailable')); return; } const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = () => { if (!request.result.objectStoreNames.contains(STORE)) request.result.createObjectStore(STORE); }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error ?? new Error('IndexedDB open failed')); request.onblocked = () => reject(new Error('IndexedDB open blocked')); }); const isEntry = (value: unknown): value is BookingOperationEntry => { const entry = value as Partial | null | undefined; return entry?.version === 1 && typeof entry.operationId === 'string' && typeof entry.key === 'string'; }; /** * Reads one key and decides what to write in the same transaction. Resolves with the decision only once the * transaction completes: a quota refusal aborts after its requests succeeded. */ const onKey = async ( mode: IDBTransactionMode, key: string, decide: (found: BookingOperationEntry | null, store: IDBObjectStore) => T, ): Promise => { const db = await openDatabase(); try { return await new Promise((resolve, reject) => { const tx = mode === 'readwrite' ? db.transaction(STORE, mode, { durability: 'strict' }) : db.transaction(STORE, mode); const store = tx.objectStore(STORE); let decided: { value: T } | undefined; const request = store.get(key); request.onsuccess = () => { try { decided = { value: decide(isEntry(request.result) ? request.result : null, store) }; } catch (error) { reject(error); tx.abort(); } }; tx.oncomplete = () => (decided ? resolve(decided.value) : reject(new Error('IndexedDB transaction ended undecided'))); tx.onabort = () => reject(tx.error ?? new Error('IndexedDB transaction aborted')); tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed')); }); } finally { db.close(); } }; export const indexedDbBookingOperationStorage = (): BookingOperationStorage => ({ read: (key) => onKey('readonly', key, (found) => found), getOrCreate: (entry) => onKey('readwrite', entry.key, (found, store) => { if (found) return { entry: found, created: false }; store.put(entry, entry.key); return { entry, created: true }; }), updateIf: (next) => onKey('readwrite', next.key, (found, store) => { if (found?.operationId !== next.operationId) return false; store.put(next, next.key); return true; }), removeIf: (key, operationId) => onKey('readwrite', key, (found, store) => { if (found?.operationId !== operationId) return false; store.delete(key); return true; }), }); export const memoryBookingOperationStorage = (): BookingOperationStorage => { const entries = new Map(); return { async read(key) { return entries.get(key) ?? null; }, async getOrCreate(entry) { const found = entries.get(entry.key); if (found) return { entry: found, created: false }; entries.set(entry.key, { ...entry }); return { entry, created: true }; }, async updateIf(next) { if (entries.get(next.key)?.operationId !== next.operationId) return false; entries.set(next.key, { ...next }); return true; }, async removeIf(key, operationId) { if (entries.get(key)?.operationId !== operationId) return false; entries.delete(key); return true; }, }; }; let storage: BookingOperationStorage = indexedDbBookingOperationStorage(); /** Replaces where operations are kept; for tests, which run without IndexedDB. */ export const setBookingOperationStorage = (next: BookingOperationStorage): void => { storage = next; }; /** The operation still open for this binding, if any. A store that cannot be read has nothing to offer. */ export const findOpenBookingOperation = async (binding: BookingOperationBinding): Promise => { try { return await storage.read(bookingOperationKey(binding)); } catch (error) { console.error('Could not read the open booking operation:', error); return null; } }; /** * The operation a new booking sends under, persisted before anything is sent. An operation already open for this * binding is never replaced: the opener that finds one resumes it and sends nothing. Throws * BookingStorageUnavailableError when it cannot be kept. */ export const beginBookingOperation = async ( binding: BookingOperationBinding, now = Date.now(), ): Promise<{ entry: BookingOperationEntry; resumed: boolean }> => { const key = bookingOperationKey(binding); try { const { entry, created } = await storage.getOrCreate({ version: 1, key, operationId: crypto.randomUUID(), state: 'sending', startedAt: now, }); return { entry, resumed: !created }; } catch (error) { console.error('Could not keep the booking operation:', error); throw new BookingStorageUnavailableError(); } }; export const noteBookingUnconfirmed = async (entry: BookingOperationEntry): Promise => { try { await storage.updateIf({ ...entry, state: 'unconfirmed' }); } catch (error) { // The entry written before the send still holds the id; only its state is stale. console.error('Could not record the unconfirmed booking:', error); } }; /** Forgets an operation whose outcome is known, only if it is still the one open for its binding. */ export const closeBookingOperation = async (entry: BookingOperationEntry): Promise => { try { await storage.removeIf(entry.key, entry.operationId); } catch (error) { console.error('Could not close the booking operation:', error); } }; const BOOKING_OPERATION_CODE_URL = 'https://akashichealthrecords.com/fhir/StructureDefinition/booking-operation-code'; const BOOKING_OPERATION_OUTCOME_URL = 'https://akashichealthrecords.com/fhir/StructureDefinition/booking-operation-outcome'; const BOOKING_OPERATION_HANDLE_URL = 'https://akashichealthrecords.com/fhir/StructureDefinition/booking-operation-handle'; /** * The only refusal that lets a device forget an operation: the server's kept outcome, naming this operation as not * made. A mismatch, a missing record, a timeout or any other refusal leaves the outcome unknown. */ export const isOwnedNotMadeAnswer = (body: unknown, operationId: string): boolean => { const extension = (body as { extension?: Array<{ url?: string; valueCode?: string; valueString?: string }> } | null | undefined)?.extension ?? []; return extension.some((each) => each.url === BOOKING_OPERATION_OUTCOME_URL && each.valueCode === 'not-made') && extension.some((each) => each.url === BOOKING_OPERATION_HANDLE_URL && each.valueString === operationId); }; /** The server's 202: the booking is neither confirmed nor refused yet, and only this operation may find out. */ export const isBookingUnconfirmedAnswer = (body: unknown): boolean => { const outcome = body as { resourceType?: string; extension?: Array<{ url?: string; valueCode?: string }> } | null | undefined; return outcome?.resourceType === 'OperationOutcome' && (outcome.extension ?? []).some((each) => each.url === BOOKING_OPERATION_CODE_URL && each.valueCode === 'booking-unconfirmed'); }; /** A booking whose outcome is not known yet: it may have been made, so the same operation must be checked. */ export class BookingUnconfirmedError extends Error { constructor(public operationId?: string, public cause?: unknown) { super('This booking is not confirmed yet.'); this.name = 'BookingUnconfirmedError'; } } /** What a check of an operation found. `failed` carries the error for the caller to classify. */ export type BookingCheck = | { kind: 'booked'; appointment: T } | { kind: 'unconfirmed' } | { kind: 'failed'; error: unknown };