// The vocabulary the bookability resolver speaks. Ordered by BAND, so the first blocker in a sorted list is // always the one to fix first - a user told "no hours set" when the role is also deactivated would fix the // wrong thing. // // MIRROR, NEVER A GATE. Nothing in this file or its consumers authorizes anything. The server's // Appointment/post.ts remains the only enforcement path. If a future change makes a client-side resolver // authoritative, that is a security regression, not a simplification. export type BookabilityBlocker = // Band 1 - is this schedule block live at all? | 'ROLE_INACTIVE' // Immediately after ROLE_INACTIVE because it is the same KIND of fact - a property of the person's // role rather than of this block - and it stops everyone, staff and patient alike. Ahead of the two // block flags below: those are per-block choices a practice made, while no seat means this person // cannot be booked anywhere at all, so it is the cheaper thing to be told first. | 'NO_CLINICIAN_SEAT' | 'BLOCK_BOOKING_DISABLED' | 'BLOCK_SELF_BOOKING_DISABLED' // Band 1 (telehealth, card 113) — does this practice offer video visits at all? Last in the band // because it is a MODALITY gate, not a "is this block live" one: a deactivated role or a block with // booking switched off is a harder stop, and leading with "we don't do video" when the provider is // actually deactivated would send someone to fix the wrong thing. | 'ORG_TELEHEALTH_DISABLED' // Service-level | 'SERVICE_INACTIVE' | 'SERVICE_NOT_OFFERED_AT_LOCATION' // Ahead of the NOT_SELF_BOOKABLE_* codes deliberately: this one stops EVERYONE, they stop patients // only, so this is the more fundamental reason and belongs first. | 'SERVICE_NOT_TELEHEALTH_ELIGIBLE' | 'NOT_SELF_BOOKABLE_ORG' | 'NOT_SELF_BOOKABLE_PRACTITIONER' | 'NOT_SELF_BOOKABLE_BLOCK' // Band 2 — does it match this combination? | 'PRACTITIONER_NOT_OFFERING_SERVICE' | 'BLOCK_DOES_NOT_OFFER_SERVICE' | 'NO_SCHEDULE_AT_LOCATION' // The direct analogue of NO_SCHEDULE_AT_LOCATION, one modality over, and sits next to it for that // reason: the block is live and offers the service, but has no virtual location to hold it. | 'NO_VIRTUAL_LOCATION_ON_BLOCK' | 'NEW_PATIENT_EXAM_NO_ELIGIBLE_BLOCK' // Band 3 — does it yield times? | 'BLOCK_NO_HOURS' | 'BLOCK_NO_PLANNING_HORIZON' | 'BLOCK_HORIZON_EXPIRED'; // Config that is silently BROADER than the org probably intended. These are NOT errors and are NOT being // removed - they are what lets a small practice skip steps. They are reported because they are invisible. export type BookabilityWidener = | 'WIDENED_ALL_SERVICES' | 'WIDENED_ALL_LOCATIONS' | 'WIDENED_SELF_BOOKABLE_BY_DEFAULT'; export const BLOCKER_ORDER: readonly BookabilityBlocker[] = [ 'ROLE_INACTIVE', 'NO_CLINICIAN_SEAT', 'BLOCK_BOOKING_DISABLED', 'BLOCK_SELF_BOOKING_DISABLED', 'ORG_TELEHEALTH_DISABLED', 'SERVICE_INACTIVE', 'SERVICE_NOT_OFFERED_AT_LOCATION', 'SERVICE_NOT_TELEHEALTH_ELIGIBLE', 'NOT_SELF_BOOKABLE_ORG', 'NOT_SELF_BOOKABLE_PRACTITIONER', 'NOT_SELF_BOOKABLE_BLOCK', 'PRACTITIONER_NOT_OFFERING_SERVICE', 'BLOCK_DOES_NOT_OFFER_SERVICE', 'NO_SCHEDULE_AT_LOCATION', 'NO_VIRTUAL_LOCATION_ON_BLOCK', 'NEW_PATIENT_EXAM_NO_ELIGIBLE_BLOCK', 'BLOCK_NO_HOURS', 'BLOCK_NO_PLANNING_HORIZON', 'BLOCK_HORIZON_EXPIRED', ] as const; // THE DISTINCTION THAT KEEPS THIS HONEST: a GAP is something missing or broken; a BY-DESIGN blocker is a // deliberate curation choice that is working exactly as configured. // // "Patients can't self-book this" is NOT "this can't be booked". A practice legitimately keeps complex // procedures and triage-needed consults behind the front desk - `schedule-allow-booking: true` + // `schedule-allow-patient-self-booking: false` is a first-class, supported configuration. // // Conflating them is actively harmful, not just imprecise: it flags a correct clinical decision as a fault, // and the obvious "fix" is to switch patient self-booking ON for a service the practice deliberately gated. // Never colour these amber, and never count them as problems in a rollup. const BY_DESIGN: ReadonlySet = new Set([ 'BLOCK_SELF_BOOKING_DISABLED', 'NOT_SELF_BOOKABLE_ORG', 'NOT_SELF_BOOKABLE_PRACTITIONER', 'NOT_SELF_BOOKABLE_BLOCK', //. A practice that does not offer video, and a service deliberately marked not-eligible for // it, are both curation working exactly as configured. Class either as a GAP and the why-not panel // tells a practice that video surgery is a problem to fix. 'ORG_TELEHEALTH_DISABLED', 'SERVICE_NOT_TELEHEALTH_ELIGIBLE', ]); export const isByDesign = (code: BookabilityBlocker): boolean => BY_DESIGN.has(code); // A SECOND, ORTHOGONAL AXIS - added by, and the distinction is easy to lose. // // `isByDesign` answers "is this deliberate config or something broken?" - it drives how the why-not // panel FRAMES a blocker. It does NOT answer "who does this stop?", and until telehealth arrived // nothing needed it to: every by-design code was also patient-only, so `staffBookable` could be derived // as "no gap blockers" and be accidentally right. // // The telehealth gates break that coincidence. They are by-design AND they stop everyone - a practice // with telehealth switched off cannot take a video booking from the front desk either, and a service // marked not-eligible for video is not eligible when a receptionist books it. Deriving staff-bookability // from the by-design set would report such a booking as staff-bookable, which is wrong and silent. // // This set is the narrow question: which blockers stop PATIENTS ONLY, letting staff book through? // It is deliberately an allowlist rather than "by-design minus telehealth": a NEW blocker nobody has // classified must fail CLOSED (stop staff until someone decides otherwise). The old derivation failed // open, which is how a new by-design code would silently have become staff-bookable. const PATIENT_ONLY: ReadonlySet = new Set([ 'BLOCK_SELF_BOOKING_DISABLED', 'NOT_SELF_BOOKABLE_ORG', 'NOT_SELF_BOOKABLE_PRACTITIONER', 'NOT_SELF_BOOKABLE_BLOCK', ]); /** Stops a patient self-booking, but the front desk can still book it. See PATIENT_ONLY above. */ export const isPatientOnly = (code: BookabilityBlocker): boolean => PATIENT_ONLY.has(code); /** Blockers that represent something missing or broken - the ones worth flagging and fixing. */ export const gapBlockers = (blockers: BookabilityBlocker[]): BookabilityBlocker[] => blockers.filter((c) => !isByDesign(c)); /** Blockers that are deliberate configuration - staff can still book; patients simply can't self-book. */ export const byDesignBlockers = (blockers: BookabilityBlocker[]): BookabilityBlocker[] => blockers.filter(isByDesign); const RANK = new Map(BLOCKER_ORDER.map((code, i) => [code, i])); // Copies before sorting: callers pass arrays they still own (and Array.prototype.sort mutates in place). export const sortBlockers = (blockers: BookabilityBlocker[]): BookabilityBlocker[] => [...blockers].sort( (a, b) => (RANK.get(a) ?? Number.MAX_SAFE_INTEGER) - (RANK.get(b) ?? Number.MAX_SAFE_INTEGER), );