import type { PractitionerRole } from "fhir/r4"; import type { PractitionerWithRoles } from "../services/PractitionerService.tsx"; import { ORGANIZATION_ROLE_GROUPS, STAFF_WORKSPACES, SUPPORT_ORGANIZATION_ROLE_CODES, WORKSPACE_REQUIRED_ORG_ROLE, getOrgRoleByCode, getOrganizationRoleCodes, type StaffWorkspace, } from "./staffRoleCatalog.ts"; import { isClinicianEnabled } from "./clinicianEnabled.ts"; /** * Who a PATIENT may start a conversation with.. * * THE DEFECT THIS EXISTS FOR: a patient could not start a conversation with ANYONE. The Providers tab * read "(0)" at every practice - *"There are no organizations with patient-facing providers available"* - * so the front-desk pool two sessions had just shipped had **no patient-side door at all**. * * ROOT CAUSE, AND IT IS A SEAM RATHER THAN A BUG ON EITHER SIDE. The picker classified staff by * `role.meta.tag` - the caller's Cognito group - and **the patient-facing projection deliberately strips * `meta`** (/228: internal auth grouping is not a patient's business). Proven with a two-token * control on the same query in the same minute: the patient gets all 8 practitioners with `meta` ABSENT, * a staff token gets `meta.tag`. For a patient the group was **always `undefined`**, so every * practitioner failed the test and "Front desk" could never render. * **Both halves are individually correct**, which is exactly why no session reviewing its own half * would have seen it. * * **DO NOT FIX THIS BY RETURNING THE TAG TO THE PATIENT PROJECTION.** That leaks a Cognito group to * patients to satisfy a client-side classifier, and undoes work that was done deliberately. * * So everything here reads the role's **`code` codings**, which the patient's payload already carries * (`receptionist`, `physician`, `family-medicine-physician`,...). No server change, no new disclosure. * * Why it survived: a patient's EXISTING threads list and open normally - they come from the thread * endpoint, not from this picker. Messaging looked alive from the inbox; only *starting* one was dead. * **A smoke test that opens an existing thread passes.** */ const RECEPTIONIST_ROLE_CODE = "receptionist"; const activeRolesOf = (practitionerWithRoles: PractitionerWithRoles): PractitionerRole[] => (practitionerWithRoles.roles ?? []).filter((role) => role.active !== false); /** * - OUR OWN VOCABULARY ONLY, and the rule below is meaningless without it. * * The test is "this code is NOT a known support code", and the support list is built from the * organization-role catalog. A code from ANOTHER vocabulary can never be in that list, so for a SNOMED * coding the test is always true - which is how `Medical record administrator` and `Administrative * healthcare staff` came to be offered to patients as people to message. * A rule and its exclusion list have to be drawn from the SAME vocabulary. They were not. */ const roleCodes = (role: PractitionerRole): string[] => getOrganizationRoleCodes(role); /** * Is this role one a patient should be able to write to individually? * * RESILIENT BY DESIGN, and deliberately the same rule the booking service picker uses * (`isBookableServiceRole`): a role qualifies unless it is a KNOWN SUPPORT role. A practice's CUSTOM * clinical role is not in our catalog, and an allowlist would silently drop it - a patient at that * practice would find their own clinician unmessageable, with nothing on screen to explain it. * The exclusion list is what carries the meaning, and it is the one that can be checked. * * - WHAT CHANGED, AND THE ONE CONSEQUENCE WORTH KNOWING. The denylist survives; what was * wrong was its INPUT. Now that only organization-role codes reach it, a role recorded ONLY in another * vocabulary (a NUCC or SNOMED stratum, of which staging holds a great many) has no code here at all and * is **not individually messageable**. * THAT IS THE DELIBERATE DIRECTION, and it is only affordable because the front desk now works: the * fallback for a clinician we cannot classify is not "the patient can reach nobody", it is "the patient * reaches the practice". Before landed, that sentence was not true and this fix would have been * the wrong trade. * WHY NOT READ THOSE CODES ANYWAY: measured on staging, `organization-role` is the ONLY system showing * genuine variety (14 distinct across 43 codings) while NUCC shows 106 codings of ONE value - bulk seed * data, not per-person classification, which rules * `organization-role` authoritative for role category and says neither of the others should be consulted * to decide whether a role is clinical. Guessing from seed data is what offered HR to patients. */ export const isPatientMessageableRole = (role: PractitionerRole): boolean => { const codes = roleCodes(role); // A clinician seat is a clinician: an invitation that set no role code still made someone patients see. if (codes.length === 0) return isClinicianEnabled(role); return codes.some((code) => !SUPPORT_ORGANIZATION_ROLE_CODES.has(code)); }; /** * SOMEONE HOLDING BOTH ROLES STAYS INDIVIDUALLY LISTED. §5's rule, unchanged: the test is * whether they hold a patient-facing role, NOT whether they lack a receptionist one. A clinician who also * covers the desk is still someone a patient may write to about their care. */ export const isPractitionerMessageableByPatient = ( practitionerWithRoles: PractitionerWithRoles, ): boolean => activeRolesOf(practitionerWithRoles).some(isPatientMessageableRole); /** * Practices with at least one active receptionist. * * DERIVED, NEVER AUTHORED. Offering "Front desk" at a practice with no receptionist is a door onto * nobody: the pool resolves its members BY ROLE at read time, so the message would sit unread with nobody * it could notify. */ /** * THE ROLE LABEL A PATIENT SEES, DERIVED FROM WHAT A PATIENT ACTUALLY RECEIVES * * THE DEFECT: `MessagesPage`'s label derivation runs every role through `getCognitoGroup`, which reads * `role.meta.tag` - **the same field /228 strip from the patient projection**, and the same field * whose absence made the whole Providers picker empty (the header of this file). It returns `undefined` * for every role a patient holds, so the label list comes back empty and the page falls through to its * `"Staff"` floor. **A patient saw their own physician labelled "Staff", with the clipboard icon**, and * every clinician at the practice looking identical to the billing clerk. * * **This was named on and deferred there for a good reason** - the page's priority map and its * `toDisplayRoleLabel` are keyed on Cognito groups, so re-sourcing them wholesale would have changed what * STAFF see too, which is a bigger change than the missing door that card was fixing. * **The narrow fix that avoids that: this is the FALLBACK, consulted only when the Cognito group is * absent.** A staff caller has `meta.tag` and never reaches here, so their labels are byte-identical; a * patient never has it, so they always do. * * **NOT to be fixed by returning `meta.tag` to the patient projection** - that leaks an internal auth * group to patients to satisfy a client-side classifier, and undoes deliberate work. * * Codes with no catalog entry are DROPPED rather than guessed at. A practice's custom coding has no * display we can vouch for, and the page's `"Staff"` floor is the honest answer for a role we cannot name * - better than echoing a raw slug at a patient. */ export const organizationRoleLabelsForPatient = ( roles: readonly PractitionerRole[] | undefined, ): string[] => { const codes = (roles ?? []) .filter((role) => role.active !== false) .flatMap((role) => getOrganizationRoleCodes(role)); const labelled = Array.from(new Set(codes)) .map((code) => ({ code, display: getOrgRoleByCode(code)?.display })) .filter((entry): entry is { code: string; display: string } => Boolean(entry.display)); /* * CLINICAL FIRST, and this is the same intent the Cognito path expresses with its priority map * (practitioner 1, receptionist 2) - restated in the vocabulary a patient's payload actually carries *, which rules `organization-role` authoritative. * It matters for exactly the person §5 called out: someone who covers the desk AND sees * patients must read as their clinical role first, because that is the one the patient is writing to. * `sort` is stable in every engine we run on, so equal-rank codes keep the order the roles gave them. */ return labelled .sort((a, b) => rankOfCode(a.code) - rankOfCode(b.code)) .map((entry) => entry.display); }; const rankOfCode = (code: string): number => SUPPORT_ORGANIZATION_ROLE_CODES.has(code) ? 1 : 0; /** * The displays of every patient-facing catalog role, lowercased - the vocabulary the labels above emit. * Built once from the SAME groups the labels come from, so the two cannot drift: a role added to the * catalog is clinical here the moment it is clinical there. */ const CLINICAL_ROLE_DISPLAYS = new Set( ORGANIZATION_ROLE_GROUPS .filter((group) => group.patientFacing) .flatMap((group) => group.items.map((item) => item.display.toLowerCase())), ); /** * Does this rendered role label describe someone CLINICAL? Used to pick the stethoscope over the * clipboard. * * WHY THIS IS NOT JUST `includes("practitioner")`, which is what the page did. That substring test was * written against the COGNITO vocabulary, where a clinician's label is literally `"Practitioner"`. The * patient's labels come from the org-role catalog instead - *"Family Medicine Physician"*, *"Registered * Nurse"* - and **none of them contain the word**, so fixing only the label would have left every doctor * drawn with the receptionist's clipboard. The label and the icon are one defect, not two. * * The two legacy substrings are KEPT rather than replaced: a staff caller's label is still * `"Practitioner"`, and dropping them would silently regress the arm this change is supposed to leave * byte-identical. * * Tolerates the page's `"