import type { Practitioner, PractitionerRole, Schedule } from 'fhir/r4'; import type { PractitionerWithRoles } from '../services/PractitionerService'; import { holdsClinicalRole } from './staffRoleCatalog'; import { isClinicianEnabled } from './clinicianEnabled'; import { resolveBookability, type BookabilityContext, type BookabilityResult, } from './booking/resolveBookability'; import type { BookabilityBlocker } from './booking/bookabilityReasons'; // Whether a provider can be booked BY A PATIENT, and if not, why. // // This module used to re-implement bookability: it counted "offers a service" from // PractitionerRole.healthcareService while the patient flow matched Schedule.actor, and nothing kept the two // in step - so it could report a provider ready when no patient could book them. It now DERIVES from the same // resolver the patient flow uses, which is why that class of disagreement is no longer expressible. // // Dropping the old ScheduleService import is also what makes this file unit-testable at all: that import // reaches environments/environment.ts, which touches `window` at module scope. That is why there was never a // test here. // // MIRROR, NEVER A GATE - advisory display only; it authorizes nothing. export type ProviderReadiness = { practitioner: Practitioner; roles: PractitionerRole[]; isProvider: boolean; /** * Can a PATIENT self-book this provider? The narrow question, and NOT the one the onboarding checklist * should ask - a practice that deliberately keeps a provider behind the front desk is not misconfigured. */ patientSelfBookable: boolean; /** * Can the practice take an appointment for this provider AT ALL? This is "ready" for setup purposes * (Spencer, 2026-07-28: bookable-by-anyone counts). True for a staff-book-only provider. */ staffBookable: boolean; /** False when the provider has no schedule block at all - a different message from "blocked". */ hasAnySchedule: boolean; /** Blockers of the block closest to working. Empty when bookable, or when there are no blocks. */ blockers: BookabilityBlocker[]; /** * What the booking verdict stands on. Only 'confirmed' may be shown as ready or not ready: otherwise the services * could not be verified, or one this provider's blocks name is owed a delete answer. */ basis: 'confirmed' | 'checking' | 'unavailable'; }; const namedServices = (schedules: Schedule[], roles: PractitionerRole[]): Set => new Set([ ...schedules.flatMap((s) => (s.actor ?? []).map((a) => a.reference ?? '')), ...roles.flatMap((r) => (r.healthcareService ?? []).map((ref) => ref.reference ?? '')), ].filter((ref) => ref.startsWith('HealthcareService/')).map((ref) => ref.split('/')[1])); const readinessBasis = (ctx: BookabilityContext, own: Schedule[], roles: PractitionerRole[], staffBookable: boolean): ProviderReadiness['basis'] => { const basis = ctx.serviceBasis; if (!basis) return 'confirmed'; if (basis.state !== 'complete') return basis.state; if (staffBookable) return 'confirmed'; const named = namedServices(own, roles); return basis.unconfirmed.some((id) => named.has(id)) ? 'unavailable' : 'confirmed'; }; /** * THIS PERSON'S OWN schedule blocks. * * Exported because a SECOND caller now needs the same set - the readiness sentence reads each block's * not-available periods to say whether the person is on leave. A schedule carries BOTH a `Practitioner/` * and a `PractitionerRole/` actor (see `ScheduleService.transformToFhirSchedule`), so "whose block is * this?" has two plausible answers and they can disagree - a block written before a role was replaced * matches one and not the other. Two callers each picking their own predicate is how the readiness * line and the booking verdict end up describing different sets of blocks, which is precisely the class * of drift this module's header records already paying for once. */ export const schedulesForPractitioner = ( entry: PractitionerWithRoles, schedulesList: Schedule[], ): Schedule[] => schedulesList.filter((s) => (s.actor ?? []).some((a) => a.reference === `Practitioner/${entry.practitioner.id}`), ); export const getProviderReadiness = ( entry: PractitionerWithRoles, schedulesList: Schedule[], ctx: BookabilityContext, ): ProviderReadiness => { const isProvider = holdsClinicalRole(entry.roles); const own = schedulesForPractitioner(entry, schedulesList); const results = own.length ? resolveBookability(own, {}, ctx) : []; const patientSelfBookable = results.some((r) => r.bookable); const staffBookable = results.some((r) => r.staffBookable); // When nothing works, surface the block CLOSEST to working - the cheapest fix. The union across blocks // would mix causes from unrelated blocks; taking the first would often name the block furthest from // working, sending someone off to fix the wrong thing. const closest = patientSelfBookable ? undefined : results.reduce( (best, r) => (!best || r.blockers.length < best.blockers.length ? r : best), undefined, ); // THE SEAT IS A PROPERTY OF THE PERSON, NOT OF A SCHEDULE BLOCK, SO IT HAS TO BE ANSWERABLE WITHOUT ONE. // // `NO_CLINICIAN_SEAT` is raised inside scheduleUsable, per block. A provider with no blocks at all never // reaches that code, so their blocker list came back EMPTY and every surface fell through to "no schedule // set up yet" - including for somebody whose actual and only problem is that they hold no seat. // // Walked on staging 2026-08-30: of three seatless providers, the two with no schedule were told to build // a schedule and the one who already had a schedule was correctly told about the seat. So the advice was // wrong exactly when the practice had done the least work. Building that schedule does not make them // bookable, and only on the second pass does anything mention the seat - two trips, the first wasted. // // Read across ALL of the person's roles: the seat is stored per role and a person may hold several, so // any enabled role means they hold a seat. const holdsClinicianSeat = entry.roles.some((role) => isClinicianEnabled(role)); const blockers: BookabilityBlocker[] = own.length ? closest?.blockers ?? [] : isProvider && !holdsClinicianSeat ? ['NO_CLINICIAN_SEAT'] : []; return { practitioner: entry.practitioner, roles: entry.roles, isProvider, patientSelfBookable: isProvider && patientSelfBookable, staffBookable: isProvider && staffBookable, hasAnySchedule: own.length > 0, blockers, basis: readinessBasis(ctx, own, entry.roles, isProvider && staffBookable), }; }; // Providers with a real GAP - something missing or broken. A provider the practice deliberately keeps // staff-book-only is NOT "not ready", and listing them here would flag a correct decision as a fault. export const getNotReadyProviders = ( practitioners: PractitionerWithRoles[], schedulesList: Schedule[], ctx: BookabilityContext, ): ProviderReadiness[] => practitioners .map((p) => getProviderReadiness(p, schedulesList, ctx)) .filter((r) => r.isProvider && !r.staffBookable && r.basis === 'confirmed');