import type { Appointment, Slot } from 'fhir/r4'; import { appointmentVersion, COMPANIONS_UNCONFIRMED } from '../appointmentVersion'; import { isRecordChanged } from '../recordChanged'; import { TIMED_OUT_MAY_HAVE_SAVED, type SaveOperation, type SaveTransport } from '../saveOperation'; import { settleUnknownWrite, writeOutcomeUnknown } from '../appointmentWriteOutcome'; export interface RescheduleDeps { /** A busy Slot from a booking caller comes back as that caller's hold. */ createHold: (slot: Slot, transport: SaveTransport) => Promise; releaseBookingHold: (hold: Slot) => Promise; updateAppointment: ( appointment: Appointment, options: { basedOn: string; onFollowUp: (followUps: string[]) => void; transport?: SaveTransport }, ) => Promise; /** The Appointment as stored now, read by id (never a cached copy). */ readStored: (id: string, transport: SaveTransport) => Promise; } export interface RescheduleResult { saved: Appointment; /** What the server saved but could not finish (X-Appointment-Follow-Up). */ followUps: string[]; } /** A save whose answer never came, which a fresh read could not settle; nothing is sent again until it is known. */ export class RescheduleUnconfirmed extends Error { constructor() { super(TIMED_OUT_MAY_HAVE_SAVED); this.name = 'RescheduleUnconfirmed'; } } const isVersion = (value: string | undefined): value is string => Boolean(value) && Number.isFinite(Number(value)); const canonical = (value: unknown): string => JSON.stringify(value, (_key, inner: unknown) => ( inner && typeof inner === 'object' && !Array.isArray(inner) ? Object.fromEntries(Object.entries(inner as Record).sort(([a], [b]) => a.localeCompare(b))) : inner )); // The stored record carries this edit: every field it changed from its basis now reads as the edit wrote it. A newer // version alone is not that evidence, since someone else's change makes one too. const carriesEdit = (basis: Appointment, payload: Appointment, stored: Appointment): boolean => { const fields = [...new Set([...Object.keys(basis), ...Object.keys(payload)])] .filter((field) => field !== 'meta' && field !== 'slot') .filter((field) => canonical(basis[field as keyof Appointment]) !== canonical(payload[field as keyof Appointment])); return fields.length > 0 && fields.every((field) => canonical(stored[field as keyof Appointment]) === canonical(payload[field as keyof Appointment])); }; /** * Saves an edited Appointment over the version it was opened on. A new time always moves onto a new hold, on the same * schedule too; the original Slot is never touched from here, since the server frees it once the Appointment is * saved. A refusal the server answered definitely gives the unused hold back, and a save refused because the * appointment changed is said as such and nothing more. A save whose answer never came is read back by id before * anything else is said or sent. */ export async function rescheduleAppointment(params: { basis: Appointment; payload: Appointment; /** The Slot the new time needs, when the time or schedule changed. */ newTime?: Slot; operation: SaveOperation; /** A recovery of its own, for a read after this save has run out of time. */ recovery: () => SaveOperation; deps: RescheduleDeps; isDefiniteRefusal: (error: unknown) => boolean; }): Promise { const { basis, operation, deps } = params; const payload: Appointment = { ...params.payload }; const basedOn = appointmentVersion(basis); let hold: Slot | undefined; let followUps: string[] = []; try { if (params.newTime) { hold = await operation.step(() => deps.createHold(params.newTime as Slot, operation.transport)); if (!hold.id) throw new Error('The replacement schedule slot was created without an ID.'); payload.slot = [{ reference: `Slot/${hold.id}` }]; } let saved: Appointment; try { saved = await operation.step(() => deps.updateAppointment(payload, { basedOn, onFollowUp: (reported) => { followUps = reported; }, transport: operation.transport, }), true); } catch (writeError) { if (!writeOutcomeUnknown(writeError)) throw writeError; // Landed means the stored visit is this edit: on this hold for a move, otherwise newer than its basis and // carrying the fields this edit changed. const heldId = hold?.id; let stored: Appointment | undefined; const settled = await settleUnknownWrite( async () => { const reading = params.recovery(); stored = await reading.step(() => deps.readStored(basis.id as string, reading.transport)); return stored; }, (current) => (heldId ? (current.slot ?? []).some((reference) => reference.reference === `Slot/${heldId}`) : isVersion(current.meta?.versionId) && Number(current.meta?.versionId) > Number(basedOn) && carriesEdit(basis, payload, current)), ); if (settled === 'saved' && stored) return { saved: stored, followUps: [COMPANIONS_UNCONFIRMED] }; throw new RescheduleUnconfirmed(); } // The Appointment response is the source of truth: if the server did not bind the new time, it did not move. if (hold) { const bound = (saved.slot ?? []).some((reference) => reference.reference === `Slot/${hold?.id}`); if (!bound) { throw new Error('The appointment update did not retain the replacement schedule slot. The original slot was left intact.'); } } return { saved, followUps }; } catch (error) { if (hold && (isRecordChanged(error) || params.isDefiniteRefusal(error))) { void deps.releaseBookingHold(hold).catch(() => undefined); } throw error; } }