import type { HealthcareService, PractitionerRole, Schedule } from 'fhir/r4'; import { resolveScheduleSelfBookableServices, resolveScheduleWithheldServices } from './scheduleSelfBookableServices'; import { resolveServiceTelehealthEligible } from '../../config/serviceTelehealthEligible'; import { detectWideners } from './bookabilityWideners'; import { isClinicianEnabled } from '../clinicianEnabled'; import type { AvailabilityEventLike } from './scheduleYieldsTimes'; import type { ExamGate } from './matchSchedules'; import type { BookabilityContext, ServiceBasisFacts } from './resolveBookability'; import { schedulePractitionerId } from './schedulePractitioner'; // The ONE place a staff surface assembles a BookabilityContext from already-loaded FHIR lists. // S3 and S4 reuse this - they must not build their own, or the answers drift apart again. // // WHY bookingFlags AND availabilityEvents ARE INJECTED rather than imported: // - getScheduleAvailabilityEvents is a hook-bound closure inside useScheduleService() - NOT a module export. // - getScheduleBookingFlags IS a module export, but it lives in ScheduleService.tsx, which imports // environments/environment.ts, which touches `window` at module scope. Importing it here would make this // module unloadable in a node-environment test - the exact reason practitionerBookingReadiness.ts never // had one. // Injecting keeps this module pure and testable, and keeps the caller responsible for calling the hook // versions CORRECTLY. // // MIRROR, NEVER A GATE. /** Sentinel for "no location preference". Only consulted when a location criterion is actually passed. */ export const ANY_LOCATION = '__all__'; export interface StaffBookabilityInput { healthcareServicesList: HealthcareService[]; /** The list is this practice's whole catalogue, so a block naming a service outside it offers nothing. */ catalogueComplete?: boolean; /** The catalogue's basis, when the caller has one: readiness says which basis it stands on. */ serviceBasis?: ServiceBasisFacts; /** Number of Locations the org has - used only to decide whether an all-locations widening is worth saying. */ locationCount: number; roleForSchedule: (s: Schedule) => PractitionerRole | undefined; bookingFlags: (s: Schedule) => { allowBooking: boolean; allowPatientSelfBooking: boolean; allowNewPatientGeneralExam: boolean; }; /** * MUST be called the way the patient flow calls it - getScheduleAvailabilityEvents(schedule, * transformFromFhirPractitionerRole(role)). Feeding a raw PractitionerRole instead of the re-transformed * form shape yields ZERO availability, silently. */ availabilityEvents: (s: Schedule) => AvailabilityEventLike[]; examGate: ExamGate; now: Date; /** * - the org's virtual (telehealth) Location id, when it has created one. Telehealth is a * LOCATION, so this is what lets a caller express "book this by video" at all. * * OMITTING IT IS THE FAIL-CLOSED DEFAULT: with no virtual location a video booking cannot be * expressed, so none of the telehealth gates can fire and nothing changes for a practice that has not * set telehealth up. Every caller that predates gets exactly that. */ virtualLocationId?: string; /** org policy. Defaults to false - deploying telehealth must not enable every practice. */ telehealthEnabled?: boolean; } export function buildStaffBookabilityContext(input: StaffBookabilityInput): BookabilityContext { const serviceById = new Map( (input.healthcareServicesList ?? []).map((s) => [s.id, s]), ); const catalogue = { catalogueComplete: input.catalogueComplete === true }; const refIds = (schedule: Schedule, prefix: string): string[] => (schedule.actor ?? []) .filter((a) => a.reference?.startsWith(`${prefix}/`)) .map((a) => a.reference!.split('/').pop() as string); return { audience: 'patient-self-booking', ...(input.serviceBasis ? { serviceBasis: input.serviceBasis } : {}), now: input.now, anyLocation: ANY_LOCATION, examGate: input.examGate, // - always present so the shape is stable, but INERT without a virtual location id: the // resolver only treats a booking as telehealth when `criteria.location` equals that id, and an // undefined id can never match. That is what keeps every pre-113 caller unaffected. telehealth: { virtualLocationId: input.virtualLocationId, orgEnabled: input.telehealthEnabled === true, // resolver, not a second opinion assembled here. The practitioner's per-service // override wins over the service default, and that logic must have exactly one home. serviceEligible: (s, serviceId) => resolveServiceTelehealthEligible(serviceById.get(serviceId), input.roleForSchedule(s)), }, accessors: { selfBookableServices: (s) => resolveScheduleSelfBookableServices(s, input.roleForSchedule(s), serviceById, catalogue), withheldServices: (s) => resolveScheduleWithheldServices(s, input.roleForSchedule(s), serviceById, catalogue), roleId: (s) => input.roleForSchedule(s)?.id, locationIds: (s) => refIds(s, 'Location'), practitionerId: (s) => schedulePractitionerId(s, input.roleForSchedule(s)), allowsNewPatientGeneralExam: (s) => input.bookingFlags(s).allowNewPatientGeneralExam, bookingFlags: (s) => { const flags = input.bookingFlags(s); return { allowBooking: flags.allowBooking, allowPatientSelfBooking: flags.allowPatientSelfBooking, }; }, roleActive: (s) => input.roleForSchedule(s)?.active, // The SAME predicate the two booking funnels apply, read from the SAME role link - so a surface // that mirrors bookability from this resolver can no longer disagree with the picker about who // can be booked. `isClinicianEnabled` reads `=== true`, so a schedule with no resolvable role // fails closed here, which is the direction the seat is meant to fail in. roleHoldsClinicianSeat: (s) => isClinicianEnabled(input.roleForSchedule(s)), availabilityEvents: (s) => input.availabilityEvents(s), wideners: (s) => detectWideners({ practitionerOfferedServiceCount: (input.roleForSchedule(s)?.healthcareService ?? []).length, orgCatalogSize: input.healthcareServicesList.length, // Per-SERVICE facts, and this accessor is per-SCHEDULE. Reported as "explicitly set" so the channel // UNDER-reports rather than claiming something false. S4 computes these properly against a specific // (service, practitioner, location) triple. serviceLocationCount: 1, orgLocationCount: input.locationCount, selfBookableSetExplicitly: true, }), }, }; }