import type { Observation, PractitionerRole } from 'fhir/r4'; import { holdsClinicianSeat } from './clinicianEnabled'; import { LAPSED_SEAT_REASON, practitionerIdOfRole, seatCapacityAdmits, type SeatStanding } from './seatStanding'; /** * MAY THIS VIEWER EDIT THIS RECORD - asked before the form is drawn, not after they typed. * * The surfaces ask a ROLE question: `userRole === PRACTITIONER`. The server asks whether the caller may * perform this particular write, and for clinical resources its answer is the paid clinician seat, * resolved per request. Those two differ every day: an unseated practitioner - a medical assistant, a * nurse rooming patients, a clinician whose seat was turned off this morning - passes the role check, * is offered Edit, Save and Archive, types, and is refused at save with a sentence about seats. * * That is the denial rule one step earlier in time. A refusal renders as a denial naming what is * denied, and an editor that looks writable and refuses at save has already spent the work. * * IT MIRRORS THE SERVER RATHER THAN DECIDING ANYTHING. A client "can I edit" that disagrees with the * authorizer either hides work somebody may do or offers work they may not - the second being the * defect this exists to fix, reintroduced by its own fix. Every rule below has a named counterpart in * `clinicianSeatWriteGuard.ts`, and a test pins the two together across the two deployables, which * cannot import from one another. * * WHAT IT DOES NOT ANSWER. Whether the record belongs to another practice: that rule exists on the * server for CarePlan alone (`evaluateCarePlanAuthorship`), and `carePlanAuthorship` is its client * mirror, already asked on the plan list and the plan page. Nothing else carries a cross-organization * write rule, so nothing else may be drawn read-only on that ground here. */ /** * The resource types whose writes the server gates on a seat, mirroring `SEAT_GOVERNED_RESOURCE_TYPES`. * * PINNED BY A TEST, NOT BY A COMMENT. A missing entry does not throw; it silently offers an editor that * will be refused, which is the failure this file exists to stop, so the drift test reads the server's * own list off the sibling infrastructure worktree and compares. * * `EncounterNoteDraft` is on the server's list and is deliberately absent here: it is not a FHIR type * and has no list page or editor of this shape, and the drift test knows about that one exception by * name rather than by a rule that would hide the next real omission. */ export const SEAT_GOVERNED_RESOURCE_TYPES = [ 'Observation', 'Condition', 'Procedure', 'ClinicalImpression', 'MedicationRequest', 'ServiceRequest', 'CarePlan', 'Goal', 'AdverseEvent', 'DetectedIssue', 'RiskAssessment', 'DeviceRequest', 'NutritionOrder', 'VisionPrescription', 'ImmunizationRecommendation', 'ImmunizationEvaluation', ] as const; export type SeatGovernedResourceType = typeof SEAT_GOVERNED_RESOURCE_TYPES[number]; /** `http://terminology.hl7.org/CodeSystem/observation-category` - the code that means vitals. */ export const VITAL_SIGNS_CATEGORY = 'vital-signs'; /** Shared seat-enablement instructions for the chart and overview. */ export const CLINICIAN_SEAT_GRANTED_BY = 'An administrator can enable one for you under Staff.'; /** The sentence to render where the editor would have been. It names the seat and who can grant one. */ export const CLINICIAN_SEAT_REQUIRED_REASON = 'This is part of seeing a patient and needs a clinician seat. ' + CLINICIAN_SEAT_GRANTED_BY; const seatGoverned: ReadonlySet = new Set(SEAT_GOVERNED_RESOURCE_TYPES); /** * Does this particular write need a seat? * * THE CATEGORY IS THE DISCRIMINATOR ON `Observation`, NOT THE RESOURCE TYPE, because an unseated staff * member logging the blood pressure they just took writes the same resource a clinician records an * assessment finding in. Spencer, 2026-09-04: *"Someone rooming a patient still needs to be able to * write because they have to take and log vitals and initial information."* * * An UNCATEGORISED Observation needs a seat. "We could not tell what this is" must never read as * "vitals", and EVERY coding must be vitals rather than some - one true coding must not carry an * arbitrary second one past the rule. Both of those are the server's wording, kept because a paraphrase * is how two copies of a rule start to differ. */ export const writeNeedsClinicianSeat = ( resourceType: string, resource?: Pick, ): boolean => { if (!seatGoverned.has(resourceType)) return false; if (resourceType !== 'Observation') return true; const codings = (resource?.category ?? []).flatMap((category) => category.coding ?? []); if (codings.length === 0) return true; return !codings.every((coding) => coding.code === VITAL_SIGNS_CATEGORY); }; export interface RecordEditContext { /** The seat standing the caller subscribed to (useSeatStanding), so a change of standing renders again. */ seatStanding: SeatStanding | null; /** The resource type the editor would write. */ resourceType: string; /** The stored resource, when there is one. A new record has none and the rule still applies. */ resource?: Pick; /** * Is the viewer STAFF - somebody whose account is a Practitioner on the server? * * NOT "is their workspace the practitioner one". The server asks `authContext.resourceType !== * 'Practitioner'`, and every staff member at a practice is a Practitioner resource whichever hat * they are wearing - so a receptionist adding a Goal meets this guard exactly as a clinician does. * Reading it as the workspace would let a surface offer a write the server then refuses, which is * the whole failure being fixed. * * A patient writing their own record and a proxy writing for somebody else are governed by their * own boundaries; the server's guard is a no-op for them and so is this. */ viewerIsStaff: boolean; /** Every PractitionerRole the viewer holds here. Any active seated role is enough. */ practitionerRoles: PractitionerRole[] | undefined | null; } export interface RecordEditDecision { mayEdit: boolean; /** Why not, ready to render beside the record. `undefined` when it may be edited. */ reason?: string; } /** * FAILS CLOSED ON THE SEAT, matching the server: an absent flag is not enabled. The direction is chosen * rather than inherited - a hidden Edit button is visible and fixable in a minute, and an editor that * refuses after the typing is the thing being fixed. */ export const mayEditRecord = (context: RecordEditContext): RecordEditDecision => { if (!context.viewerIsStaff) return { mayEdit: true }; if (!writeNeedsClinicianSeat(context.resourceType, context.resource)) return { mayEdit: true }; if (holdsClinicianSeat(context.practitionerRoles)) { // While the practice is lapsed a seat flag is configuration kept for a restore; only the free seat writes. const viewer = (context.practitionerRoles ?? []).map(practitionerIdOfRole).find(Boolean); return seatCapacityAdmits(viewer, context.seatStanding) ? { mayEdit: true } : { mayEdit: false, reason: LAPSED_SEAT_REASON }; } return { mayEdit: false, reason: CLINICIAN_SEAT_REQUIRED_REASON }; };