import type { Schedule } from 'fhir/r4'; import { resolveBookability, type BookabilityContext } from './resolveBookability'; import { sortBlockers, type BookabilityBlocker, type BookabilityWidener } from './bookabilityReasons'; // Resolves bookability across a SCOPE and returns it as COMBINATIONS. // // The unit is a combination - (service × practitioner × location) - not a resource. That is the entire point // of this epic: an org authors along resource axes (services here, staff there, schedules in a third place) // while a patient books a combination, and the product has never once displayed the combination. Everyone has // been doing a four-way join in their head against rules they cannot see. // // MIRROR, NEVER A GATE. export type BookabilityScope = | { kind: 'organization' } | { kind: 'practitioner'; practitionerId: string } | { kind: 'schedule'; scheduleId: string }; export interface Combination { serviceId: string | undefined; serviceName: string; practitionerId: string | undefined; practitionerName: string; /** Undefined for an "all locations" block - the empty-means-all convention, preserved as a label. */ locationId: string | undefined; locationName: string; /** Can a PATIENT self-book this combination? */ bookable: boolean; /** Can the practice take an appointment for it at all? A staff-book-only row is true here, false above. */ staffBookable: boolean; blockers: BookabilityBlocker[]; wideners: BookabilityWidener[]; } export interface ScopeInput { schedules: Schedule[]; ctx: BookabilityContext; practitionerNameById: Map; locationNameById: Map; /** Only used to word the "all N services" widening. Optional: absent simply reads as 0 rather than failing. */ orgCatalogSize?: number; } export interface ScopeResult { combinations: Combination[]; bookableCount: number; totalCount: number; } const inScope = (schedule: Schedule, scope: BookabilityScope, ctx: BookabilityContext): boolean => { switch (scope.kind) { case 'organization': return true; case 'practitioner': return ctx.accessors.practitionerId(schedule) === scope.practitionerId; case 'schedule': return schedule.id === scope.scheduleId; default: return false; } }; export function resolveScope(scope: BookabilityScope, input: ScopeInput): ScopeResult { const { ctx } = input; const schedules = input.schedules.filter((s) => inScope(s, scope, ctx)); // Keyed by the triple so the same combination reachable through two blocks appears once - a patient only // needs ONE working path, so a bookable row must win over an unbookable duplicate. const byTriple = new Map(); for (const schedule of schedules) { const practitionerId = ctx.accessors.practitionerId(schedule); const practitionerName = practitionerId ? input.practitionerNameById.get(practitionerId) ?? practitionerId : 'Unknown provider'; // An empty location list is the "all locations" convention - kept as ONE labelled row rather than dropped, // so a block that is deliberately site-agnostic is still visible. const locationIds = ctx.accessors.locationIds(schedule); const locations: (string | undefined)[] = locationIds.length ? locationIds : [undefined]; // A block offering nothing still deserves a row - it is exactly the case someone needs to see. const services = ctx.accessors.selfBookableServices(schedule); const serviceRows = services.length ? services : [{ id: undefined as string | undefined, display: 'No services offered' }]; for (const service of serviceRows) { for (const locationId of locations) { // - ONE VERDICT PER COMBINATION. This used to be a SINGLE // `resolveBookability([schedule], {}, ctx)` per schedule, hoisted above these loops, whose result // was copied onto every row. The grid therefore presented per-combination answers to a question // nobody had asked per combination. // // AND IT FAILED OPEN, which is the dangerous direction - measured, not reasoned. With `{}`, // `criteria.location` is undefined so `wantsTelehealth` is false and NO telehealth blocker can // fire; `criteria.service` is undefined so band 2 never consults services. A combination the // server will refuse - Physical examination × Video Visit - therefore rendered BOOKABLE. The // panel built to eliminate false greens was producing one, and staff acting on it would burn a // patient's slot on a booking that gets refused. // // The triple below is exactly the triple the row displays, so the verdict now answers the // question the row asks. // Costs one evaluation per (service × location) instead of one per schedule. These are pure // functions over already-loaded FHIR - no I/O - and correctness of a false-green surface is worth // more than the arithmetic. Bands 1 and 3 do not depend on criteria and are recomputed; hoisting // them would mean reimplementing resolveBookability's composition here, which is how the panel // and the patient's option list drift apart (epic 063 exists to stop exactly that). // // Do NOT revert this to a hoisted call to "save work" without re-reading: the empty // criteria are not a detail, they are the defect. const [result] = resolveBookability( [schedule], { service: service.id, location: locationId, practitionerId }, ctx, ); const combination: Combination = { serviceId: service.id, serviceName: service.display, practitionerId, practitionerName, locationId, locationName: locationId ? input.locationNameById.get(locationId) ?? locationId : 'All locations', bookable: result?.bookable ?? false, staffBookable: result?.staffBookable ?? false, blockers: result?.blockers ?? [], wideners: result?.wideners ?? [], }; const key = `${service.id ?? '-'}|${practitionerId ?? '-'}|${locationId ?? '-'}`; const existing = byTriple.get(key); if (!existing || (!existing.bookable && combination.bookable)) { byTriple.set(key, combination); } } } // Services staff book but patients cannot are named rather than dropped, so nobody wonders where they went. // The block's own verdict still applies; a bookable path through another block wins. for (const withheld of ctx.accessors.withheldServices?.(schedule) ?? []) { for (const locationId of locations) { const key = `${withheld.id}|${practitionerId ?? '-'}|${locationId ?? '-'}`; if (byTriple.has(key)) continue; const [result] = resolveBookability([schedule], { location: locationId, practitionerId }, ctx); byTriple.set(key, { serviceId: withheld.id, serviceName: withheld.display, practitionerId, practitionerName, locationId, locationName: locationId ? input.locationNameById.get(locationId) ?? locationId : 'All locations', bookable: false, staffBookable: result?.staffBookable ?? false, blockers: sortBlockers(Array.from(new Set([...(result?.blockers ?? []), withheld.blocker]))), wideners: [], }); } } } const combinations = Array.from(byTriple.values()); return { combinations, bookableCount: combinations.filter((c) => c.bookable).length, totalCount: combinations.length, }; }