// The patient-facing agreements gate: what the notice lists, and what an audit is allowed to render. // // THE NAMES ARE NOT HERE, AND THAT IS THE POINT. They live in `config/consentInstruments.ts`, which // mirrors the server's own table byte for byte, and every name on every screen is read from there. // This file held its own copy while the model was still being built, and two copies of a legal // document's name is the exact defect this work exists to stop - the same instrument had already picked // up three different names across the booking flow, the admin editor and the attestation, and a legal // instrument named three ways is three documents to anyone reading the evidence later. // // What stays here is what the model deliberately does NOT decide: the order the patient reads them in, // which comes from the drawing rather than from the wire, and the shape an audit answer must take. import { CONSENT_INSTRUMENT_DEFINITIONS, ConsentInstrumentId, } from '../../config/consentInstruments'; /** * The order the notice lists them in, taken from the drawing. * * It is NOT `CONSENT_INSTRUMENTS` from the config. That constant is the server's order and the wire * shape; this is a reading order a person chose - privacy first, because it is the one you are only * told, then the two you are asked. Deriving one from the other would silently re-order the notice the * next time the server table is edited for an unrelated reason. */ export const ORG_AGREEMENT_ORDER: readonly ConsentInstrumentId[] = [ 'hipaa-privacy', 'hipaa-authorization', 'consent-to-treat', ] as const; /** The order the booking review step asks them in, as the self-booking deck draws it. */ export const BOOKING_AGREEMENT_ORDER: readonly ConsentInstrumentId[] = [ 'consent-to-treat', 'hipaa-authorization', 'hipaa-privacy', ] as const; /** The name to render for one instrument. One lookup, so no screen composes a name of its own. */ export const orgAgreementName = (instrument: ConsentInstrumentId): string => CONSENT_INSTRUMENT_DEFINITIONS[instrument].name; export type OrgAgreementAnswer = 'outstanding' | 'agreed' | 'declined'; export interface OrgAgreementState { instrument: ConsentInstrumentId; answer: OrgAgreementAnswer; /** * The version of THIS document the practice is currently offering. * * THREE DOCUMENTS VERSION INDEPENDENTLY, and sharing one counter between them is how the audit * trail is lost. A practice edits its Consent to Treat in June and leaves HIPAA Privacy untouched * since March, so a patient's three answers routinely pin three different vintages. */ offeredVersion?: number; /** Shown beside the version in the modal - "version 3, effective June 1, 2026". */ effectiveDate?: string; /** * The version this patient actually answered, pinned at the moment they answered it. This is what * an audit reads back, and it is NOT the same as `offeredVersion` once the practice edits the text. */ answeredVersion?: number; } /** * What an audit gets when it asks what a document SAID at the moment somebody agreed to it. * * THE FAILURE MUST BE HONEST, and this type exists to make silence impossible to render by accident. * An audit that cannot resolve the agreed version must SAY SO. Rendering nothing is bad; rendering * TODAY'S text beside a March signature is worse - that is a false record rather than a missing one. * * Do not infer the failure from an empty result. There is a live defect where a deleted resource's * `_history` bundle returns HTTP 500 while every version is still readable one at a time, so an empty * or failed read is exactly the case most likely to arise on the resources an audit asks about. */ export type AgreedTextResolution = | { state: 'resolved'; version: number; text: string; effectiveDate?: string } | { state: 'unavailable'; version: number }; /** * THERE IS NO `treatmentIsPermitted` HERE ANY MORE, AND ITS ABSENCE IS LOAD-BEARING. * * This file carried one while the model was unbuilt. The model now answers it: the status bundle * returns `mayTreat` and `mayAccessRecord` computed SERVER-SIDE, precisely because treatment needs all * three and record access needs only the HIPAA Authorization, and a screen left to work that out from * three statuses reasons from whichever of the three its author had in mind. Read the bundle. A * second implementation of the gate is a second answer to a legal question. * * What survives here is only what the bundle cannot say: which documents the notice should still LIST. */ /** * Whether the notice still has something to ask for. * * A DECLINED document is NOT outstanding, and that is a deliberate reading of a real disagreement. * The card's control is that a decline is "final, written durably and never re-prompted - only its * consequence changed", so a declined document must never reappear as a thing to answer. The drawn * confirmation, meanwhile, tells the patient "you can agree later if you change your mind", and the * deck draws no notice state containing a declined document - so the route back is undrawn. * Never re-prompting wins here because it is a stated control; the "agree later" route needs drawing * before it can be built. Raised in the pair log. */ export const hasOutstandingAgreement = (agreements: readonly OrgAgreementState[]): boolean => agreements.some((agreement) => agreement.answer === 'outstanding'); /** * The wire's five-state status collapsed to the three answers a patient's notice can show. * * `withdrawn` reads as OUTSTANDING and `declined` does not, and that asymmetry is the whole reason * this is a named function rather than a comparison written at each call site. A withdrawal closes a * window that really opened, so the document is owed again; a decline never opened one and is an * ANSWER, which nothing built on it may chase. */ export const answerFromStatus = ( status: 'none' | 'proposed' | 'active' | 'withdrawn' | 'declined', ): OrgAgreementAnswer => { if (status === 'active') return 'agreed'; if (status === 'declined') return 'declined'; return 'outstanding'; }; /** Whether the visits a decline would cancel were read: an unread list is never presented as no visits. */ /** `partial`: read, but the list says it may be missing visits, so it is never every visit a decline cancels. */ export type VisitsRead = 'loading' | 'read' | 'partial' | 'unavailable'; /** * What a recorded decline tells its patient: only what was confirmed, by the visits read back as cancelled or by * the count the server reports. No cancellation is claimed that neither shows. */ export const declineReceipt = ({ named, serverCancelled, visitsRead, organizationName, scheduleLine }: { named: number; serverCancelled: number; visitsRead: VisitsRead; organizationName: string; scheduleLine: string; }): string => { const again = ' You can agree at any time - ask the practice, or open the documents again here.'; if (named > 0) { return `Your answer is recorded. ${named === 1 ? 'This appointment' : 'These appointments'} with ${organizationName} ${named === 1 ? 'is' : 'are'} cancelled:${scheduleLine}`; } if (serverCancelled > 0) { return `Your answer is recorded. ${serverCancelled === 1 ? 'One appointment' : `${serverCancelled} appointments`} with ${organizationName} ${serverCancelled === 1 ? 'was' : 'were'} cancelled.${scheduleLine}${again}`; } if (visitsRead === 'read') return `Your answer is recorded. No appointments were cancelled.${again}`; return `Your answer is recorded. We could not check your appointments with ${organizationName} - please check them or contact the practice.${again}`; };