import type { Schedule } from 'fhir/r4'; import type { WithheldService } from './scheduleSelfBookableServices'; import { isPatientOnly, sortBlockers, type BookabilityBlocker, type BookabilityWidener, } from './bookabilityReasons'; import { evaluateScheduleUsable, type UsableAccessors } from './scheduleUsable'; import { evaluateScheduleYieldsTimes, type YieldsTimesAccessors } from './scheduleYieldsTimes'; import { evaluateScheduleMatch, type ExamGate, type MatchCriteria, type ScheduleAccessors, } from './matchSchedules'; // THE single answer to "can a patient book this, and if not, why not?". // // MIRROR, NEVER A GATE. Nothing here authorizes anything; the server's Appointment/post.ts is the only // enforcement path. If a future change makes this authoritative, that is a security regression, not a // simplification. // // Consumers come in two kinds and MUST NOT be given separate implementations: // - the patient flow consumes bookableFrom(...) - the survivors // - staff surfaces consume the results directly - the rejections, with reasons // One code path is the whole point. Two paths kept in step by convention is how // practitionerBookingReadiness.ts came to disagree with the patient flow in the first place. export type BookabilityAudience = 'patient-self-booking' | 'staff-booking'; export interface BookabilityAccessors extends ScheduleAccessors, UsableAccessors, YieldsTimesAccessors { wideners: (s: Schedule) => BookabilityWidener[]; /** The block's services patients cannot self-book, for staff views that name them. Absent lists none. */ withheldServices?: (s: Schedule) => WithheldService[]; } /** * - the telehealth gates. Telehealth is a LOCATION, not a service: a video booking is * `criteria.location === virtualLocationId`, which is what makes video share the practitioner's * EXISTING slot pool instead of forking it into a second schedule. * * Optional as a whole, and that is the fail-closed default: an org with no virtual Location cannot * express a video booking at all, so none of these gates can fire and nothing changes for a practice * that has not set telehealth up. */ export interface TelehealthGate { /** The org's virtual Location id, if it has one. */ virtualLocationId?: string; /** The org-level policy - `telehealthEnabled`. The outermost of the three gates. */ orgEnabled: boolean; /** *, resolved per (block, service): can this service be done by video by this block's role? * * Takes the service id rather than closing over it so a context can be built once, before any criteria * exist - which is how every context builder here works. A gate that needed the criteria at BUILD time * would force callers to rebuild the context per search, and that is how two callers end up with two * slightly different contexts. */ serviceEligible: (schedule: Schedule, serviceId: string) => boolean; } /** What the service catalogue behind a context can be said to be, and which services owe a delete answer. */ export interface ServiceBasisFacts { state: 'complete' | 'checking' | 'unavailable'; unconfirmed: string[]; } export interface BookabilityContext { audience: BookabilityAudience; serviceBasis?: ServiceBasisFacts; now: Date; anyLocation: string; examGate: ExamGate; accessors: BookabilityAccessors; telehealth?: TelehealthGate; } export interface BookabilityResult { schedule: Schedule; /** Can a PATIENT self-book this? The narrow question. */ bookable: boolean; /** * Can the practice take an appointment here AT ALL - i.e. is every remaining blocker a deliberate * self-booking choice rather than a gap? A staff-book-only block is `staffBookable: true, bookable: false`, * and calling that "not bookable" would flag a correct clinical decision as a fault. */ staffBookable: boolean; blockers: BookabilityBlocker[]; wideners: BookabilityWidener[]; } export function resolveBookability( schedules: Schedule[], criteria: MatchCriteria, ctx: BookabilityContext, ): BookabilityResult[] { if (ctx.audience === 'staff-booking') { // Deliberate, not an oversight. A receptionist's bookable set is a SUPERSET of a patient's - staff paths // fail open where patient paths fail closed, and the self-bookable axis governs patient self-booking only. // Returning the patient answer here would be silently WRONG, which is worse than unavailable. throw new Error( 'resolveBookability: the staff-booking audience is not implemented. Do not fall back to the ' + 'patient answer — a staff booking set is a superset and the answer would be wrong.', ); } return schedules.map((schedule) => { const band1 = evaluateScheduleUsable(schedule, ctx.accessors); const band2 = evaluateScheduleMatch(schedule, criteria, { accessors: ctx.accessors, examGate: ctx.examGate, anyLocation: ctx.anyLocation, // NO_VIRTUAL_LOCATION_ON_BLOCK is raised in there rather than here on purpose: "does this block // sit at this location" is a band-2 matching question, and matchSchedules is the filtered VIEW of // this function. Answering it separately here would let the patient's option list and the // why-not panel disagree - the exact defect epic 063 exists to remove. virtualLocationId: ctx.telehealth?.virtualLocationId, }); const band3 = evaluateScheduleYieldsTimes(schedule, ctx.accessors, ctx.now); // A block offering NO self-bookable service at all cannot be booked by anyone, whatever the criteria. // evaluateScheduleMatch can't answer this: it only consults services when criteria.service is set, so an // empty-criteria match (which is how "is this provider bookable at all?" is asked) would otherwise report // a service-less block as bookable - a false green inside the very thing built to eliminate false greens. // // It lives HERE rather than in evaluateScheduleMatch on purpose: matchSchedules answers "does this block // match these criteria" and is consumed by the patient flow's option lists, where changing this would be a // behaviour change. resolveBookability answers the strictly narrower "can a patient book this block". // bookableFrom(resolveBookability(...)) ⊆ matchSchedules(...), deliberately. const offersNoService = ctx.accessors.selfBookableServices(schedule).length === 0; const serviceBlockers: BookabilityBlocker[] = offersNoService && !band2.blockers.includes('BLOCK_DOES_NOT_OFFER_SERVICE') ? ['BLOCK_DOES_NOT_OFFER_SERVICE'] : []; // - the org and service telehealth gates. These are NOT matching questions (unlike the // block's virtual location, which band 2 answers), so they live here alongside the other // service-level check above. // // They fire ONLY for a video booking. An in-person booking at a practice that does not offer // video is completely ordinary, and surfacing "this practice doesn't offer video visits" on a why-not // panel for a room appointment would be noise that trains people to ignore the panel. const wantsTelehealth = ctx.telehealth?.virtualLocationId !== undefined && criteria.location === ctx.telehealth.virtualLocationId; const telehealthBlockers: BookabilityBlocker[] = []; if (wantsTelehealth && ctx.telehealth) { if (!ctx.telehealth.orgEnabled) telehealthBlockers.push('ORG_TELEHEALTH_DISABLED'); // Only meaningful once a service is actually chosen - "is this provider bookable at all?" is // asked with empty criteria, and answering it with a service-eligibility blocker would be a // false negative. if (criteria.service && !ctx.telehealth.serviceEligible(schedule, criteria.service)) { telehealthBlockers.push('SERVICE_NOT_TELEHEALTH_ELIGIBLE'); } } const blockers = sortBlockers([ ...band1.blockers, ...band2.blockers, ...serviceBlockers, ...telehealthBlockers, ...band3.blockers, ]); return { schedule, bookable: band1.ok && band2.ok && band3.ok && !offersNoService && telehealthBlockers.length === 0, // Derived, never separately computed: if the only things standing in the way are deliberate // self-booking choices, the front desk can still book this. // // - CHANGED FROM `gapBlockers(blockers).length === 0`, in the same commit as the gates // that made the difference observable. The old form asked "is every blocker BY-DESIGN?", which // silently assumed by-design staff can book through it. That held only while every by-design // code was also patient-only. `ORG_TELEHEALTH_DISABLED` and `SERVICE_NOT_TELEHEALTH_ELIGIBLE` are // by-design and stop EVERYONE, so the old form reported a video visit as staff-bookable at a // practice that does not offer video - and the server would then refuse the receptionist. // // The question is the AUDIENCE one, not the classification one: can a human at the front desk get // past every remaining blocker? An allowlist of patient-only codes also fails CLOSED for any future // blocker, where the old form failed open. staffBookable: blockers.every(isPatientOnly), blockers, wideners: ctx.accessors.wideners(schedule), }; }); } export const bookableFrom = (results: BookabilityResult[]): Schedule[] => results.filter((r) => r.bookable).map((r) => r.schedule);