// The self-booking guard's answer, read from the CODE the server sends rather than from its English. // // WHY THIS EXISTS. `Appointment/post.ts` refuses a duplicate self-booking with a 409 whose body is a // well-formed OperationOutcome carrying a machine-readable guard code TWICE - once as an extension and // once in `issue.details.coding` - plus the conflicting appointment's id and start. The booking form // discarded all of it and decided what to tell the patient with: // // errorMessage.includes("active upcoming appointment with this practitioner") // // THE PROSE IS USER-FACING COPY AND MUST STAY FREE TO CHANGE. Rewording a sentence is about the // lowest-risk edit anyone makes, nothing on the server says that sentence is load-bearing, and the // failure is not an error: the patient silently drops from the guard message to a generic "Failed to // create appointment", and the form no longer resets to step 1. // Bind to the CODE. Keep the server's sentence as display text - it is good copy - never as the // discriminator. See. // // AND THE SUBSTRING WAS ALREADY WRONG, before anyone changed anything: `"already being processed"` // matches BOTH the self-booking guard (another booking for this PRACTITIONER) and the slot reservation // (another booking for this TIME), so a time collision was reported to the patient as a practitioner // conflict. Two distinct server conditions, one client branch. /** The guard outcomes `buildSelfBookingConflictResponse` can report. */ export type AppointmentBookingGuardCode = 'BOOKING_IN_PROGRESS' | 'ACTIVE_APPOINTMENT_EXISTS'; const GUARD_CODE_EXTENSION_URL = 'https://akashichealthrecords.com/fhir/StructureDefinition/appointment-booking-guard-code'; const EXISTING_APPOINTMENT_START_URL = 'https://akashichealthrecords.com/fhir/StructureDefinition/existing-appointment-start'; const GUARD_CODE_SYSTEM = 'https://akashichealthrecords.com/fhir/CodeSystem/appointment-booking-guard'; const GUARD_CODES: readonly string[] = ['BOOKING_IN_PROGRESS', 'ACTIVE_APPOINTMENT_EXISTS']; export interface AppointmentGuardOutcome { code: AppointmentBookingGuardCode; /** ISO instant of the appointment already held, when the server named one. */ existingAppointmentStart?: string; } const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; const asArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : []); /** * Reads the guard outcome from an error body, or `null` when this is not a guard refusal. * * CHECKS BOTH PLACES THE SERVER PUTS THE CODE - the extension first, then * `issue[].details.coding[]`. The server sends both today; reading either means dropping one on the * server side degrades this to the other rather than silently reverting the patient to a generic * error message. * * Returns null rather than guessing. A body that is not a recognised guard refusal must fall through * to the caller's ordinary error handling - inventing a code here would put the patient on a * "you already have an appointment" path for an unrelated failure. */ export function readAppointmentGuardOutcome(body: unknown): AppointmentGuardOutcome | null { if (!isRecord(body)) return null; const extensions = asArray(body.extension).filter(isRecord); const fromExtension = extensions .find((ext) => ext.url === GUARD_CODE_EXTENSION_URL)?.valueCode; const fromIssue = asArray(body.issue) .filter(isRecord) .flatMap((issue) => (isRecord(issue.details) ? asArray(issue.details.coding) : [])) .filter(isRecord) .find((coding) => coding.system === GUARD_CODE_SYSTEM)?.code; const code = [fromExtension, fromIssue] .find((candidate): candidate is AppointmentBookingGuardCode => typeof candidate === 'string' && GUARD_CODES.includes(candidate)); if (!code) return null; const existingAppointmentStart = extensions .find((ext) => ext.url === EXISTING_APPOINTMENT_START_URL)?.valueInstant; return { code, ...(typeof existingAppointmentStart === 'string' && existingAppointmentStart ? { existingAppointmentStart } : {}), }; } /** * The slot-reservation refusal, which is a DIFFERENT extension from the guard codes above. * * That difference is the whole reason this exists. `readAppointmentGuardOutcome` looks for * `appointment-booking-guard-code` and returns null for a taken slot, so a contested time fell * through to the caller's generic failure path and reached the patient as * "Failed to create appointment: ERROR: ..." - a failure wearing a denial's sentence. * * Measured 2026-09-10 by racing two patients onto one slot: the loser gets 409 with this code. */ const SLOT_RESERVATION_EXTENSION_URL = 'https://akashichealthrecords.com/fhir/StructureDefinition/appointment-slot-reservation-code'; export type SlotReservationCode = 'SLOT_ALREADY_BOOKED' | 'BOOKING_IN_PROGRESS' | 'SLOT_NOT_OPEN'; // The server sends BOOKING_IN_PROGRESS here too, when another booking still holds the time, and // SLOT_NOT_OPEN when the practice has since closed the time the patient picked. const SLOT_RESERVATION_CODES: readonly string[] = ['SLOT_ALREADY_BOOKED', 'BOOKING_IN_PROGRESS', 'SLOT_NOT_OPEN']; /** Reads the slot-reservation refusal from an error body, or null when this is not one. */ export function readSlotReservationCode(body: unknown): SlotReservationCode | null { if (!isRecord(body)) return null; const code = asArray(body.extension) .filter(isRecord) .find((ext) => ext.url === SLOT_RESERVATION_EXTENSION_URL)?.valueCode; return typeof code === 'string' && SLOT_RESERVATION_CODES.includes(code) ? (code as SlotReservationCode) : null; } /** * A refusal renders as a denial naming what is denied, never as a failure. Ruled 2026-09-06. * * The server's own sentence already names it - "This time is no longer available. Please choose * another time." What it arrives wearing is an internal severity prefix that belongs in a log and * not in front of a patient, so it is TAKEN OFF rather than reworded. Nothing here invents copy: * rewording a server's denial in the client is how the two drift apart. */ export function asDenial(message: string): string { return message.replace(/^\s*(ERROR|WARNING|FATAL)\s*:\s*/i, '').trim(); }