import type { Appointment, Slot } from 'fhir/r4'; import type { ReassignAssessment } from './reassignFeasibility'; import { appointmentVersion, COMPANIONS_UNCONFIRMED } from '../appointmentVersion'; import { isBookingHoldId } from './bookingHold'; import { settleUnknownWrite, writeOutcomeUnknown } from '../appointmentWriteOutcome'; /** *, slice 2 - MOVE the appointments the feasibility engine cleared. * * Deliberately separate from `reassignFeasibility`: deciding and doing have different failure modes and * different tests. This module assumes the decision has already been made and refuses to second-guess it - * except for the one guard below, which is cheap and would be catastrophic to get wrong. * * WHAT THE ORDER BUYS, per appointment * 1. **Create the Slot on the TARGET's schedule.** The visit's time is held before anything is changed, * so two reassignments running at once cannot both claim it. * 2. **PUT the appointment** pointing at that Slot with the practitioner swapped. The server frees the * OLD slot itself (`Appointment/put.ts`'s reschedule path) - the client must not, and cannot: there * is no `DELETE /R4/Slot` and freeing someone else's slot from here would race that handler. * 3. **If the PUT fails, mark the new Slot `free` again.** Without this, a failed move leaves a BUSY * slot on the target's calendar holding a time for an appointment that never arrived - an hour nobody * can book and nobody can explain. There is no delete, so `free` IS the rollback. * * EVERY FAILURE IS REPORTED PER APPOINTMENT, never summarised. A batch that swallows per-item errors * makes "it worked" meaningless - and here the unreported item is * a patient still booked with someone who has left. */ export interface ReassignTarget { /** The practitioner the visits move TO. */ practitionerId: string; /** Their role at this organization - the participant the org calendar and booking gate both read. */ practitionerRoleId: string; } export interface ReassignDeps { /** A busy Slot from a booking caller comes back as that caller's hold. */ createSlot: (slot: Slot) => Promise; releaseBookingHold: (hold: Slot) => Promise; updateAppointment: (appointment: Appointment, options: { basedOn: string; onFollowUp: (followUps: string[]) => void }) => Promise; /** Throws once the moves no longer belong to what started them; nothing further is sent. */ admit?: () => void; /** The Appointment as stored now, for a move whose answer never came. */ readNow?: (appointmentId: string) => Promise; } export interface MoveOutcome { readonly appointmentId: string | undefined; readonly moved: boolean; /** Present when `moved` is false. Plain enough to show a receptionist. */ readonly error?: string; /** True when the hold taken for the move was given back because the move failed. */ readonly rolledBack?: boolean; /** What a saved move left unfinished on the schedule. */ readonly followUps?: string[]; /** Its answer never came and a fresh read could not settle it; nothing after it was sent. */ readonly uncertain?: boolean; } const isPractitionerActor = (reference: string | undefined): boolean => reference?.startsWith('Practitioner/') === true || reference?.startsWith('PractitionerRole/') === true; /** * Swap who the visit is with, and change nothing else. * * `meta` RIDES THROUGH UNTOUCHED, and that is load-bearing rather than incidental. A FHIR PUT replaces * the WHOLE resource, so rebuilding the appointment instead of spreading it would drop `meta.tag` and strip * the organisation stamp - which since is the ONLY thing that keeps an appointment visible to its * own practice. Reassigning a visit into invisibility * would be a worse version of the defect this card exists to fix. */ export function reassignedAppointment( appointment: Appointment, target: ReassignTarget, slotId: string, ): Appointment { const kept = (appointment.participant ?? []).filter((participant) => !isPractitionerActor(participant.actor?.reference)); return { ...appointment, slot: [{ reference: `Slot/${slotId}` }], participant: [ ...kept, { actor: { reference: `PractitionerRole/${target.practitionerRoleId}` }, status: 'accepted' }, { actor: { reference: `Practitioner/${target.practitionerId}` }, status: 'accepted' }, ], }; } /** The Slot to hold this visit's time on the target's schedule. */ export function slotForMove(appointment: Appointment, scheduleId: string): Slot { const start = appointment.start!; const parsedEnd = Date.parse(appointment.end ?? ''); const end = Number.isNaN(parsedEnd) ? new Date(Date.parse(start) + (appointment.minutesDuration ?? 0) * 60_000).toISOString() : appointment.end!; return { resourceType: 'Slot', schedule: { reference: `Schedule/${scheduleId}` }, // `busy`, not `free`: the slot exists to HOLD this time. A `free` slot would leave the hour open // for someone else to book while the appointment points at it. status: 'busy', start, end, // Carried so the moved visit still describes what kind of visit it is - the same fields the // booking flow copies between Slot and Appointment. ...(appointment.serviceType ? { serviceType: appointment.serviceType } : {}), ...(appointment.serviceCategory ? { serviceCategory: appointment.serviceCategory } : {}), ...(appointment.appointmentType ? { appointmentType: appointment.appointmentType } : {}), }; } /** * Move every assessment that came back feasible. * * It runs them ONE AT A TIME, not in parallel. Two moves into the same target schedule can want * overlapping times, and firing them together turns that into a race the server resolves arbitrarily - * sequential means the second one's slot creation is the thing that fails, visibly, on the item that * caused it. */ export async function executeReassign( assessments: ReadonlyArray, target: ReassignTarget, deps: ReassignDeps, ): Promise { const outcomes: MoveOutcome[] = []; for (const assessment of assessments) { const { appointment, feasible, scheduleId } = assessment; // The one guard this module keeps: never move something the feasibility engine refused. Callers // filter to `plan.movable`, but a `blocked` assessment arriving here would be moved into a slot the // booking gate rejected - silently, because nothing downstream re-checks. if (!feasible || !scheduleId) { outcomes.push({ appointmentId: appointment.id, moved: false, error: 'This appointment was not cleared to move.', }); continue; } deps.admit?.(); let created: Slot | undefined; let retired = false; try { // The version assessed, so a move never lands over a change made since. const basedOn = appointmentVersion(appointment); created = await deps.createSlot(slotForMove(appointment, scheduleId)); try { deps.admit?.(); } catch (retirement) { retired = true; throw retirement; } let followUps: string[] = []; await deps.updateAppointment(reassignedAppointment(appointment, target, created.id!), { basedOn, onFollowUp: (reported) => { followUps = reported; }, }); outcomes.push({ appointmentId: appointment.id, moved: true, ...(followUps.length > 0 ? { followUps } : {}) }); } catch (error: any) { const message = String(error?.message ?? error); let rolledBack = false; // A move whose answer never came is read again: moved onto this hold, it landed; still where it was, // it did not; unreadable, it is unknown and nothing more is moved. if (!retired && created?.id && writeOutcomeUnknown(error) && deps.readNow) { const holdId = created.id; const settled = await settleUnknownWrite( () => (deps.readNow as (id: string) => Promise)(appointment.id as string), (stored) => (stored.slot ?? []).some((reference) => reference.reference === `Slot/${holdId}`), ); if (settled === 'saved') { outcomes.push({ appointmentId: appointment.id, moved: true, followUps: [COMPANIONS_UNCONFIRMED] }); continue; } if (settled === 'unknown') { outcomes.push({ appointmentId: appointment.id, moved: false, uncertain: true, error: 'We could not confirm whether this appointment was moved. Check it before trying again.', }); return outcomes; } } // The hold was not used; give it back. A hold the server already took is the server's to free. if (created?.id && isBookingHoldId(created.id)) { try { await deps.releaseBookingHold(created); rolledBack = true; } catch (releaseError) { // Reported, never swallowed; an unreleased hold expires on its own and blocks nothing meanwhile. console.error(`Failed to give back hold ${created.id} after a failed reassignment:`, releaseError); } } // Nothing more is moved for what is no longer on screen. if (retired) throw error; outcomes.push({ appointmentId: appointment.id, moved: false, error: message, rolledBack }); } } return outcomes; }