import type { Schedule } from 'fhir/r4'; import type { BookabilityBlocker } from './bookabilityReasons'; import type { BandResult } from './scheduleUsable'; // The ONE place a schedule is judged against the patient's criteria. Every derived option list (service, // location, practitioner, and the card-040 ranker candidates) must go through this, so that a predicate which // can exclude a schedule is applied at EVERY level of the funnel rather than only the last one. That was the // defect: the exam gate filtered practitioners but not services or locations, so a location could be offered // with zero practitioners behind it. // // FHIR resources are reached through injected accessors rather than imported helpers, which keeps this module // free of React context and therefore genuinely unit-testable. export interface ScheduleAccessors { selfBookableServices: (s: Schedule) => { id: string; display: string }[]; roleId: (s: Schedule) => string | undefined; /** Explicit site ids. EMPTY means the role is org-wide ("All locations") and matches any site. */ locationIds: (s: Schedule) => string[]; practitionerId: (s: Schedule) => string | undefined; allowsNewPatientGeneralExam: (s: Schedule) => boolean; } export interface ExamGate { restrictToNewPatientExam: boolean; /** Whether a service is typed as the new patient consultation. Absent means none is, so the gate fails closed. */ isConsultationService?: (serviceId: string) => boolean; } export interface MatchContext { accessors: ScheduleAccessors; examGate: ExamGate; /** Sentinel meaning "no location preference"; matters only to the UI, never to matching. */ anyLocation: string; /** * The org's virtual (telehealth) Location id, when it has one.: telehealth is a LOCATION, so * a video booking arrives here as `criteria.location === virtualLocationId`. Undefined means the org * has no virtual location, so a video booking cannot be expressed and nothing below changes. */ virtualLocationId?: string; } export interface MatchCriteria { service?: string; location?: string; practitionerId?: string; } // A patient not yet seen may book only a consult-typed service, on a block whose practitioner takes new // patients. The consult is recognised by its type code, whatever the practice named it. function passesExamGate(schedule: Schedule, criteria: MatchCriteria, ctx: MatchContext): boolean { if (!ctx.examGate.restrictToNewPatientExam) return true; if (!ctx.accessors.allowsNewPatientGeneralExam(schedule)) return false; const isConsultation = ctx.examGate.isConsultationService ?? (() => false); if (criteria.service) return isConsultation(criteria.service); return ctx.accessors.selfBookableServices(schedule).some((svc) => isConsultation(svc.id)); } // BAND 2 of bookability, with reasons. matchSchedules below is the filtered VIEW of this - ONE code path, so // a staff-facing explanation can never disagree with what the patient flow actually offers. Two paths kept in // step by convention is exactly how practitionerBookingReadiness.ts came to contradict this file. export function evaluateScheduleMatch( schedule: Schedule, criteria: MatchCriteria, ctx: MatchContext, ): BandResult { const blockers: BookabilityBlocker[] = []; let ok = true; if (!passesExamGate(schedule, criteria, ctx)) { ok = false; blockers.push('NEW_PATIENT_EXAM_NO_ELIGIBLE_BLOCK'); } if ( criteria.service && !ctx.accessors.selfBookableServices(schedule).some((svc) => svc.id === criteria.service) ) { ok = false; blockers.push('BLOCK_DOES_NOT_OFFER_SERVICE'); } if (criteria.location && criteria.location !== ctx.anyLocation) { //. A video booking is a booking AT the virtual location, so it comes through this same // branch - but under a stricter rule and with its own blocker. See below. const wantsVirtual = ctx.virtualLocationId !== undefined && criteria.location === ctx.virtualLocationId; const missing: BookabilityBlocker = wantsVirtual ? 'NO_VIRTUAL_LOCATION_ON_BLOCK' : 'NO_SCHEDULE_AT_LOCATION'; if (!ctx.accessors.roleId(schedule)) { ok = false; blockers.push(missing); } else { const siteIds = ctx.accessors.locationIds(schedule); // An EMPTY list means "all locations" - an unenforced convention, preserved deliberately. // // EXCEPT FOR THE VIRTUAL LOCATION, which must be EXPLICIT. Letting the wildcard // cover it would mean every org-wide block silently becomes video-bookable the moment the org // creates a virtual Location - no practitioner opting in, no way to see it had happened. That is // the same fail-open shape as the `filterOutRooms` blocklist on epic 109, and here it would put a // patient in a video visit with a provider who never agreed to do video. // // The wildcard is untouched for physical locations: breaking that would break ordinary in-person // booking for every practice that skipped location setup, which is exactly who the convention is // there for. const matched = wantsVirtual ? siteIds.includes(criteria.location) : siteIds.length === 0 || siteIds.includes(criteria.location); if (!matched) { ok = false; blockers.push(missing); } } } // A practitioner mismatch is not a misconfiguration - it just means this block belongs to someone else. It // excludes without producing a blocker, so a why-not panel never tells anyone to "fix" it. if (criteria.practitionerId && ctx.accessors.practitionerId(schedule) !== criteria.practitionerId) { ok = false; } return { ok, blockers }; } // The distinct self-bookable services across the given blocks, each kept only if the gate lets the patient // book it there - so a patient not yet seen is offered the consult-typed services alone. export function offeredServices( schedules: Schedule[], ctx: MatchContext, ): { id: string; display: string }[] { const seen = new Set(); const options: { id: string; display: string }[] = []; for (const schedule of schedules) { for (const svc of ctx.accessors.selfBookableServices(schedule)) { if (seen.has(svc.id) || !passesExamGate(schedule, { service: svc.id }, ctx)) continue; seen.add(svc.id); options.push(svc); } } return options; } export function matchSchedules( schedules: Schedule[], criteria: MatchCriteria, ctx: MatchContext, ): Schedule[] { return schedules.filter((schedule) => evaluateScheduleMatch(schedule, criteria, ctx).ok); }