import type { Coding, Extension, Organization, PractitionerRole } from 'fhir/r4'; import { CLINICAL_ORGANIZATION_ROLE_CODES, STAFF_ORGANIZATION_ROLE_SYSTEM, STAFF_WORKSPACES, type StaffWorkspace, getPractitionerRoleOrganizationCodings, getPractitionerRoleWorkspaces, } from './staffRoleCatalog.ts'; /** *. Is this coding eligible for a BUILT-IN permission floor? * * DELIBERATELY LENIENT ABOUT A MISSING `system` - see the server twin's comment in * aws/sam/services/api/src/utils/staff-permissions.ts (isBuiltInRoleCoding) for the full reasoning. * In short: the strict form (`system === STAFF_ORGANIZATION_ROLE_SYSTEM`) and this one differ only * for a coding with NO system, and there strict would silently REVOKE a legacy role's permissions * while buying no security - a custom role always carries its own system * (getOrganizationDefinedRoleSystem), so the collision this closes cannot arrive without one. * * The SERVER is authoritative for permissions; this copy exists so the UI does not offer a section * the API will refuse. Keep the two behaviourally identical - a divergence here shows up as a * section that appears and then errors (which is exactly what -hr-organization-permission- * diverges-between-repos describes). */ const isBuiltInRoleCoding = (coding: Pick): boolean => !coding.system || coding.system === STAFF_ORGANIZATION_ROLE_SYSTEM; // A built-in role whose floor manages schedules (HR) manages every colleague's, as the role table rules. export const floorManagesSchedules = (coding: Pick): boolean => isBuiltInRoleCoding(coding) && DEFAULT_ROLE_PERMISSION_PROFILES[coding.code ?? '']?.schedules === 'manage'; export type StaffPermissionSection = 'organization' | 'staff' | 'schedules' | 'billing' | 'campaigns' | 'clinicians' | 'messages' | 'diagnostics' | 'records' | 'records-release'; export type StaffPermissionLevel = 'none' | 'view' | 'manage'; export type StaffPermissionDefinition = { key: StaffPermissionSection; label: string; description: string; // How the settings screen renders this section. 'levels' (the default) is the None/View/Manage // picker every section has always used; 'capability' is a single yes/no toggle for a section where // `view` is meaningless - you either may do the thing or you may not (, Spencer 2026-08-05). // // This is a PRESENTATION choice only. A capability still STORES in the none/view/manage vocabulary // so the resolution path, the org extension format and the server contract are all unchanged; the // toggle simply cannot produce `view`. Use capabilityLevel/isCapabilityGranted to cross that boundary // rather than comparing to 'manage' at a call site. control?: 'levels' | 'capability'; }; /** * - the two directions of the capabilitylevel boundary, in ONE place. * * Only `manage` grants. `view` is still representable in storage (the toggle cannot produce it, but a * hand-edited Organization extension can), so it must read as NOT granted - mirroring the server's * canEnableClinicianSeat. Comparing to 'manage' at each call site is how those two drift apart, which is * failure and the reason the server has a single resolver too. */ export const isCapabilityGranted = (level: StaffPermissionLevel | undefined): boolean => level === 'manage'; export const capabilityLevel = (granted: boolean): StaffPermissionLevel => granted ? 'manage' : 'none'; /** * - WHOSE schedules, as a SECOND AXIS rather than two more rungs. * * It cannot be a level: permission checks compare `rank[held] >= rank[required]`, which forces a total * order, and `manage-own` / `view-all` have none - managing your own schedule grants no sight of anyone * else's, and seeing everyone's grants no right to change your own. Any single ordering is wrong in one * direction and **wrong silently**, because a rank comparison always returns an answer. * * On the `schedules` section ONLY. Nobody has asked for scoped billing or scoped campaigns, and a field * on every section is a migration plus five more pickers for a question no org has raised. * * MUST MATCH the server copy in akashic-infrastructure `utils/staff-permissions.ts` - same extension * child (`scope`), same values, same absent-means-`all` rule. The two files are kept in step by the tests * in both repos, not by this comment. */ export type StaffPermissionScope = 'own' | 'all'; /** * ABSENT SCOPE MEANS `all`, AND THAT IS A MIGRATION DECISION. * * Live orgs already have saved profiles carrying `schedules: 'manage'` with no scope. Reading absent as * `own` would NARROW every existing grant - including HR's - with nothing on screen to say so. Reading it * as `all` preserves exactly what those saved profiles mean today. * So the picker ALWAYS writes a scope explicitly, and nothing saved from here on depends on this * fallback. */ export const DEFAULT_SCHEDULES_SCOPE: StaffPermissionScope = 'all'; export const normalizePermissionScope = (value?: string): StaffPermissionScope | undefined => value === 'own' || value === 'all' ? value : undefined; export type OrganizationRolePermissionProfile = { role: Coding; permissions: Record; /** * - set only when the saved profile carries one. UNDEFINED IS MEANINGFUL: it marks a * profile written before scope existed, which must keep reading as `all`, so it is deliberately not * defaulted at parse time where that distinction would be lost. */ schedulesScope?: StaffPermissionScope; /** * Whether this role may OPEN the practitioner workspace. Spencer, 2026-09-03. * * UNDEFINED IS MEANINGFUL AND IS THE COMMON CASE: it means nobody has edited this setting, so the * answer comes from the role's own classification. Only an explicit true or false is the org * speaking. An org that has never opened the screen is unchanged, which is control 4 of the card * and the reason this is not defaulted at parse time. * * IT IS NOT THE SEAT. The paid clinician seat decides what you may DO once inside - see a patient * - and the two grants stay independent. Collapsing them is the exact failure the ruling exists to * prevent: a medical assistant who may room patients must not need a $50 seat to open the door. */ practitionerWorkspace?: boolean; }; export const STAFF_ROLE_PERMISSION_EXTENSION_URL = 'https://akashichealthrecords.com/fhir/StructureDefinition/organization-role-permissions'; const ROLE_CHILD_URL = 'role'; const PERMISSION_CHILD_URL = 'permission'; const PERMISSION_SECTION_CHILD_URL = 'section'; const PERMISSION_LEVEL_CHILD_URL = 'level'; // - the SCOPE child. On the `schedules` permission only; read from any other section it is // ignored, so a stray value cannot widen a section that has no scope axis. const PERMISSION_SCOPE_CHILD_URL = 'scope'; // A sibling of `role`, not a `permission` child: the sections are view/manage over DATA, and this is a // boolean grant over a WORKSPACE. Modelling it as a section would have put a fake level on it and made // "manage the practitioner workspace" a sentence somebody has to invent a meaning for. const PRACTITIONER_WORKSPACE_CHILD_URL = 'practitionerWorkspace'; export const STAFF_PERMISSION_DEFINITIONS: StaffPermissionDefinition[] = [ { key: 'organization', label: 'Organization', description: 'View organization information, locations, and healthcare services.', }, { key: 'staff', label: 'Staff', description: 'View the staff directory. Manage also allows adding staff and editing profiles, roles, and workspaces. Only an administrator can grant the Admin workspace or change these permissions.', }, { key: 'schedules', label: 'Schedules', description: 'View staff coverage or manage schedules and availability.', }, { key: 'billing', label: 'Billing', description: 'View patient invoices or create, issue, void, and take payment on them. Configuring the payment processor remains Admin-only.', }, { key: 'campaigns', label: 'Campaigns', description: 'View campaigns and audience/performance reports, or compose and send campaigns. Audiences show counts only, with no patient names, IDs, or conditions.', }, // - WHO MAY ENABLE A CLINICIAN SEAT. Its own section, NOT folded into `staff` or `billing`. // Folding it into `staff` would mean every org that lets HR administer staff has silently also let HR // commit to a recurring charge; folding it into `billing` would force an org to grant a role ALL // billing just to let it onboard a clinician. The org decides this one thing separately. // // Rendered as a YES/NO toggle, never a three-way picker (Spencer, 2026-08-05) - `view` is // meaningless for a capability. It is STORED in the same none/view/manage vocabulary as every other // section so nothing else in the resolution path changes; only `manage` grants. // // THE COPY NAMES THE FREE FIRST CLINICIAN DELIBERATELY. "Adds a paid seat" on its own is literally // FALSE for the first one, and a UI must claim only what is true. The PRICE is deliberately // absent - it lives in the billing config, and a second literal here is the card-124 drift shape. { key: 'clinicians', label: 'Clinician seats', description: 'Enabling a clinician adds a paid seat to your subscription (your first clinician is free).', control: 'capability', }, // - the one shared inbox the permission model could not express. // // THE LABEL IS SCOPED ON PURPOSE AND A BARE "Messages" WOULD BE DANGEROUS. To an admin reading the // Role Permissions screen, "Messages: none" against a practitioner reads as *"this person cannot use // messaging"* - and if it ever meant that, an org would be **cutting clinical communication with a // permission toggle**. It does not, and the row says so in words: a practitioner's own patient thread // is governed by BEING A PARTICIPANT, and nothing here can touch it. // "Shared inboxes (front desk)" beats "Messages", and the description states the exclusion rather // than leaving it to be inferred. // // view AND manage are both real here, which is why this is not a capability toggle: a practice can // let a new hire SEE the pool before letting them answer patients in the practice's name. { key: 'messages', label: 'Shared inboxes (front desk)', description: "View the front desk's shared patient inbox, or reply to and claim its conversations. This setting does not change anyone's own conversations, which depend on participation.", }, // Lab and imaging WORK -- carrying out an order, never placing one. Its own section for the // reason `clinicians` above gives for itself: folded into `staff`, every practice that lets // somebody administer staff would silently also let them file clinical results. // // THE DESCRIPTION STATES THE EXCLUSION, and that is not padding. To an admin reading this // screen, "Diagnostics: none" against a practitioner reads as "this person cannot order // tests" -- and if it meant that, a practice could stop a clinician ordering with a permission // toggle. It does not: ordering is gated on the practitioner role and nothing here reaches it. // Results also stay on the patient's chart regardless; this governs the QUEUE, the work list. // // view AND manage are both real, so this is not a capability toggle: a practice can let a new // lab tech watch the queue before trusting them to record a result against a record. { key: 'diagnostics', label: 'Lab and imaging work', description: 'View the lab and imaging queue, or work it — record collection, despatch, and results. Does NOT affect who can ORDER a test: that is a clinician\'s judgement and is unaffected by this setting. Results remain on the patient\'s chart for anyone who can see the chart.', }, // Records requests. Reaching a request never reaches a chart: what a packet may hold is decided per // item by the server, and releasing it is a separate yes or no below. { key: 'records', label: 'Records requests', description: 'View records requests and their history, or work them: log a request, ask another practice for records, and file what arrives.', }, { key: 'records-release', label: 'Release records', description: 'Approve and send a records packet outside the practice. Also needs Records requests: Manage.', control: 'capability', }, ]; export const EMPTY_STAFF_PERMISSIONS: Record = { organization: 'none', staff: 'none', schedules: 'none', billing: 'none', campaigns: 'none', clinicians: 'none', messages: 'none', diagnostics: 'none', records: 'none', 'records-release': 'none', }; // Built-in default permission floor keyed by org-role coding. Applied to any role carrying the coding, // supplement-able upward by a configured Organization profile but never reduced below it. MUST stay in // sync with the server copy in akashic-infrastructure aws/sam/services/api/src/utils/staff-permissions.ts. export const DEFAULT_ROLE_PERMISSION_PROFILES: Record< string, Record > = { // HR gets organization VIEW (not manage): it authors staff + schedules, but a bookability problem is // frequently CAUSED at the org level (a service switched off, or not offered at a location). Without view // it cannot even SEE the cause - only that something is wrong - which is a dead end for the one workspace // that owns the fragile middle of the booking chain. It still cannot change any of it. (Spencer, 7-28.) // HR holds staff:manage and STILL gets clinicians:'none'. That gap IS - enabling a // clinician is a recurring charge, and an org that trusts HR to administer staff has not thereby // agreed to let HR commit money. Every built-in role defaults to none; the org opts a role in. 'human-resources': { organization: 'view', staff: 'manage', schedules: 'manage', billing: 'none', campaigns: 'none', clinicians: 'none', messages: 'none', diagnostics: 'none', records: 'none', 'records-release': 'none' }, // Billing gets organization VIEW, ruled by Spencer 2026-09-16: not having the admin // Organization page does not mean a biller cannot PULL location and service values into an // invoice - they do not need to manage them, they need to use the values set from there. An // invoice line IS a service at a location, so the old none left the hat unable to do its one // job, and the picker answered 403 on GET /R4/HealthcareService. Four GETs, no writes. 'billing-specialist': { organization: 'view', staff: 'none', schedules: 'none', billing: 'manage', campaigns: 'none', clinicians: 'none', messages: 'none', diagnostics: 'none', records: 'none', 'records-release': 'none' }, // Marketing composes campaigns sent AS the practice - its name, branding, locations. organization:view // so it is not working blind on the facts the message asserts; it changes none of it. Everything else // 'none': a marketing manager is not a receptionist with extra buttons. // // KEYED `marketing-communications`, NOT `marketing-manager` ( S4c final review, I3). The key // must be a coding the org-role catalog actually offers, and the catalog's entry is // `marketing-communications` ("Marketing & Communications", staffRoleCatalog.ts ORGANIZATION_ROLE_GROUPS). // Keyed on `marketing-manager` this profile was unreachable through the role picker - while // addCustomOrganizationRole slugifies the free text "Marketing Manager" to exactly `marketing-manager`, // so the only way to get campaigns:manage was to hand-type a custom role. Intended path dead, unintended // path live. Renamed on BOTH sides in the same change; the server copy is // akashic-infrastructure aws/sam/services/api/src/utils/staff-permissions.ts. 'marketing-communications': { organization: 'view', staff: 'none', schedules: 'none', billing: 'none', campaigns: 'manage', clinicians: 'none', messages: 'none', diagnostics: 'none', records: 'none', 'records-release': 'none' }, }; // Workspaces whose section access is resolved from org-role permissions (+ defaults) rather than a // fixed built-in level. Everything else with no built-in mapping gets 'none'. // // Built from STAFF_WORKSPACES, not from bare string literals ( S4c final review, I4). // 'Marketing' used to be a literal here that was NOT in the StaffWorkspace union at all, so no // caller could legally pass it and the entry was dead - a fact no test could catch, because // resolveStaffPermissionLevel takes `workspace?: string`. Referencing the constants means a member // that is removed from (or misspelled against) the union now fails to COMPILE. The Set stays // string-typed so the `workspace ?? ''` lookup below still type-checks without a cast. const PERMISSION_DRIVEN_WORKSPACES = new Set([ STAFF_WORKSPACES.GENERIC, STAFF_WORKSPACES.HR, STAFF_WORKSPACES.BILLING, STAFF_WORKSPACES.MARKETING, // -- the referral coordinator role carries no permission profile, so this workspace // resolves to the same reach its holder already had. It is here because it has no BUILT-IN level: // without it, resolveStaffPermissionLevel returns none for every section and the workspace // renders as a hat with nothing behind it. STAFF_WORKSPACES.REFERRALS, // Neither tech coding carries a permission profile, so both workspaces resolve to exactly the // reach their holder already had -- which is `diagnostics`, granted by the practice. They are // here because they have NO BUILT-IN level: without this, every section resolves to none and the // workspace is a hat with nothing behind it. STAFF_WORKSPACES.LAB_TECH, STAFF_WORKSPACES.IMAGING_TECH, // The records coordinator role carries no profile either; the practice grants records itself. STAFF_WORKSPACES.RECORDS, ]); const permissionLevelRank: Record = { none: 0, view: 1, manage: 2, }; export const getCodingKey = (coding?: Coding) => { if (!coding?.code) { return ''; } return `${coding.system || ''}|${coding.code}`; }; export const normalizePermissionLevel = (value?: string): StaffPermissionLevel => { if (value === 'manage' || value === 'view') { return value; } return 'none'; }; export const getHigherPermissionLevel = ( first: StaffPermissionLevel, second: StaffPermissionLevel, ): StaffPermissionLevel => permissionLevelRank[first] >= permissionLevelRank[second] ? first : second; const getChildExtension = (extension: Extension, url: string) => extension.extension?.find((child) => child.url === url); const parsePermissionProfile = ( extension: Extension, ): OrganizationRolePermissionProfile | undefined => { const role = getChildExtension(extension, ROLE_CHILD_URL)?.valueCoding; if (!role?.code) { return undefined; } const permissions = { ...EMPTY_STAFF_PERMISSIONS }; let schedulesScope: StaffPermissionScope | undefined; (extension.extension || []) .filter((child) => child.url === PERMISSION_CHILD_URL) .forEach((permissionExtension) => { const section = getChildExtension( permissionExtension, PERMISSION_SECTION_CHILD_URL, )?.valueCode as StaffPermissionSection | undefined; const level = normalizePermissionLevel( getChildExtension(permissionExtension, PERMISSION_LEVEL_CHILD_URL)?.valueCode, ); if (section && section in permissions) { permissions[section] = level; } // - the scope belongs to `schedules` alone. if (section === 'schedules') { schedulesScope = normalizePermissionScope( getChildExtension(permissionExtension, PERMISSION_SCOPE_CHILD_URL)?.valueCode, ); } }); // Read as a tri-state on purpose: present-and-true, present-and-false, or absent. `?? undefined` // rather than `Boolean(...)`, because a missing child and an explicit `false` mean different things // and collapsing them would make every untouched org read as WITHHELD. const practitionerWorkspace = getChildExtension( extension, PRACTITIONER_WORKSPACE_CHILD_URL, )?.valueBoolean; return { role, permissions, ...(schedulesScope ? { schedulesScope } : {}), ...(practitionerWorkspace === undefined ? {} : { practitionerWorkspace }), }; }; export const getOrganizationRolePermissionProfiles = ( organization?: Organization | null, ): OrganizationRolePermissionProfile[] => (organization?.extension || []) .filter((extension) => extension.url === STAFF_ROLE_PERMISSION_EXTENSION_URL) .map(parsePermissionProfile) .filter((profile): profile is OrganizationRolePermissionProfile => Boolean(profile)); export const getOrganizationRolePermissionProfile = ( organization: Organization | null | undefined, role: Coding | undefined, ): OrganizationRolePermissionProfile | undefined => { const roleKey = getCodingKey(role); if (!roleKey) { return undefined; } return getOrganizationRolePermissionProfiles(organization) .find((profile) => getCodingKey(profile.role) === roleKey); }; const serializePermissionProfile = ( profile: OrganizationRolePermissionProfile, ): Extension | undefined => { const permissionExtensions: Extension[] = STAFF_PERMISSION_DEFINITIONS /* 🚨 CARD 333 — `schedules` IS WRITTEN EVEN AT `none`, and every other section still is not. Omitting `none` keeps the extension small, and that was harmless while no section had a DEFAULT: absent and none meant the same thing. Schedules now defaults to `view/own` for any staff role, so those two must differ — otherwise an org that deliberately picks None is indistinguishable from one that has never touched the screen, and the default silently overrides their choice. That is the same "a floor makes the matrix decorative" defect this card was raised about, arriving through storage rather than through code. */ .filter((definition) => definition.key === 'schedules' || profile.permissions[definition.key] !== 'none') .map((definition) => ({ url: PERMISSION_CHILD_URL, extension: [ { url: PERMISSION_SECTION_CHILD_URL, valueCode: definition.key, }, { url: PERMISSION_LEVEL_CHILD_URL, valueCode: profile.permissions[definition.key], }, /* - THE PICKER ALWAYS WRITES A SCOPE, EXPLICITLY. Absent means `all` for backward compatibility with profiles saved before scope existed, and that fallback must stay a MIGRATION rule rather than a live default: anything saved from here on says what it means, so nothing new depends on the fallback and it can eventually be retired without rewriting anyone's grants. Written on the `schedules` permission only - the section that has the axis. */ ...(definition.key === 'schedules' ? [{ url: PERMISSION_SCOPE_CHILD_URL, valueCode: profile.schedulesScope ?? DEFAULT_SCHEDULES_SCOPE, }] : []), ], })); if (!profile.role.code || permissionExtensions.length === 0) { return undefined; } return { url: STAFF_ROLE_PERMISSION_EXTENSION_URL, extension: [ { url: ROLE_CHILD_URL, // Persist only valid FHIR Coding fields. Built-in role options (StaffRoleOption) // carry non-FHIR catalog metadata (nucc/snomedSpecialty/specialtyRequired) that // HealthLake rejects as "Unrecognised property" on the Organization PUT. valueCoding: { system: profile.role.system, code: profile.role.code, display: profile.role.display, }, }, // WRITTEN ONLY WHEN THE ORG HAS SAID SOMETHING. Absent still means "use the role's own // classification", so a profile saved before this setting existed keeps its default rather // than being frozen at whatever the default happened to be on the day it was written - // which is the same trap the `schedules` scope comment above describes, from the other end. ...(profile.practitionerWorkspace === undefined ? [] : [{ url: PRACTITIONER_WORKSPACE_CHILD_URL, valueBoolean: profile.practitionerWorkspace, }]), ...permissionExtensions, ], }; }; export const setOrganizationRolePermissionProfile = ( organization: Organization, profile: OrganizationRolePermissionProfile, ): Organization => { const profileKey = getCodingKey(profile.role); const existingExtensions = (organization.extension || []).filter((extension) => { if (extension.url !== STAFF_ROLE_PERMISSION_EXTENSION_URL) { return true; } const existingRole = getChildExtension(extension, ROLE_CHILD_URL)?.valueCoding; return getCodingKey(existingRole) !== profileKey; }); const serializedProfile = serializePermissionProfile(profile); return { ...organization, extension: serializedProfile ? [...existingExtensions, serializedProfile] : existingExtensions.length > 0 ? existingExtensions : undefined, }; }; /** * - `Practitioner` NO LONGER CARRIES A BUILT-IN `manage` ON SCHEDULES. * * It used to return `'manage'` here, and the resolver's early exit below then returned before the org's * configured permissions were read at all. So an admin who set Schedules to `none` for Physician saw it * SAVE and saw nothing change - the control was decorative on this side, while the API granted the write * outright on the other. Any clinician could rewrite any colleague's availability, which is what patients * can book. * * **A FLOOR AND A CONFIGURABLE SETTING CANNOT COEXIST.** The resolver returns * `getHigherPermissionLevel(builtIn, configured)`, so a floor can only ever RAISE what the org chose - * while the floor said `manage`, no org choice could mean less. Spencer's ruling (2026-08-14) is that the * org decides: *"we need permissions set at the org level for this so the org can configure however best * fits their workflow."* * * Receptionist KEEPS its `view` floor: the front desk must see everyone's availability to book at all, * and `view` cannot be used to change anyone's hours. Only the `manage` floor was the hole. */ export const getBuiltInPermissionLevel = ( workspace: string | undefined, section: StaffPermissionSection, ): StaffPermissionLevel | undefined => { switch (workspace) { case 'Admin': // The two Records grants are explicit for administrators too, as the server enforces them. return section === 'records' || section === 'records-release' ? 'none' : 'manage'; case 'Practitioner': return 'none'; case 'Receptionist': return section === 'schedules' ? 'view' : 'none'; case 'PlatformManager': return 'none'; default: return undefined; } }; /** The level a built-in role carries before any practice configuration. The resolver never goes below it. */ export const getRoleDefaultPermissionLevel = ( roleCoding: Pick, section: StaffPermissionSection, ): StaffPermissionLevel => (isBuiltInRoleCoding(roleCoding) ? DEFAULT_ROLE_PERMISSION_PROFILES[roleCoding.code ?? '']?.[section] || 'none' : 'none'); export const resolveStaffPermissionLevel = ({ workspace, organization, practitionerRole, practitionerRoles, section, }: { workspace?: string; organization?: Organization | null; practitionerRole?: PractitionerRole; practitionerRoles?: PractitionerRole[]; section: StaffPermissionSection; }): StaffPermissionLevel => { const builtInLevel = getBuiltInPermissionLevel(workspace, section); /* - THE EARLY EXIT THAT USED TO SIT HERE IS GONE ENTIRELY, and that is stronger than narrowing it. It read `if (builtInLevel === 'manage') return 'manage';` under a comment saying *"Admin is always manage"*. The comment described Admin; the CONDITION described "any workspace with a built-in manage" - and `Practitioner`/`schedules` was exactly that. So every practitioner took the Admin exit for that one section and the org's configured permissions were never read. Narrowing it to `workspace === 'Admin'` was the obvious repair and it is DEAD CODE: the tail below already returns `getHigherPermissionLevel(builtInLevel, configuredLevel)`, and Admin's built-in `manage` wins there by construction. A mutation run proved it - removing the narrowed exit changed no behaviour at all. **Deleting it means this class of bug cannot recur.** There is no longer any path that returns before the matrix is consulted, so the next workspace to gain a built-in `manage` cannot inherit an exemption nobody intended for it. A floor now only ever RAISES a configured level, which is the one thing a floor should do. */ // Workspaces with no fixed built-in level draw from org-role permissions + defaults (Generic/HR/Billing). // Any other unrecognized workspace gets no access. if (builtInLevel === undefined && !PERMISSION_DRIVEN_WORKSPACES.has(workspace ?? '')) { return 'none'; } const rolesToEvaluate = practitionerRoles?.length ? practitionerRoles : practitionerRole ? [practitionerRole] : []; const organizationRoleCodings = Array.from( new Map( rolesToEvaluate .flatMap((role) => getPractitionerRoleOrganizationCodings(role)) .map((coding) => [getCodingKey(coding), coding] as const), ).values(), ); const configuredLevel = organizationRoleCodings .reduce((highestLevel, roleCoding) => { const profile = getOrganizationRolePermissionProfile(organization, roleCoding); const configuredRoleLevel = profile?.permissions[section] || 'none'; // - the built-in FLOOR is for built-in roles only, and `code` alone does not // say so. A practice-defined role slugifies its free-text name into its code, so a custom // role named "Billing" produced the built-in `billing-specialist` key and silently // inherited that floor while displaying as an ordinary bespoke role. Mirrors the server // fix in staff-permissions.ts (isBuiltInRoleCoding) - see that function's comment for why // a MISSING system still qualifies and only a FOREIGN one is rejected. const defaultRoleLevel = getRoleDefaultPermissionLevel(roleCoding, section); const roleLevel = getHigherPermissionLevel(configuredRoleLevel, defaultRoleLevel); return getHigherPermissionLevel(highestLevel, roleLevel); }, 'none'); /* - EVERY STAFF ROLE MAY SEE THEIR OWN SCHEDULE. Spencer, 2026-08-14: *"Just make the permission default for schedules 'view my own' and problem solved."* Applied to ANYONE holding an org-role coding rather than to a list of clinical roles - the choice Spencer made on 2026-08-15, because THIS FILE could tell a clinical role from an administrative one (the catalog groups by patient-facing discipline) and the API CANNOT. Copying that list server-side would go stale the moment a role is added to the catalog: the nav would show while the API refused, which is the client/server mismatch this whole card exists to remove. A uniform baseline is identical on both sides by construction mirrored in `utils/staff-permissions.ts`. WHY IT IS HERE AND NOT IN DEFAULT_ROLE_PERMISSION_PROFILES: that table is keyed by role code, and this baseline is deliberately keyless. Adding ~80 clinical entries to it would be the duplication this decision avoids. Accepted cost: a Platform Manager or billing clerk also resolves to `view` and keeps the Schedules nav, showing their own - usually empty - schedule. The SCOPE (`own`, enforced server-side) is what stops that being anything more. IT IS A DEFAULT, NOT A FLOOR, AND THE DISTINCTION IS THE WHOLE CARD. A floor is a maximum taken against the org's choice, so it can only ever RAISE it - which is exactly how the retired `Practitioner`/`schedules: manage` built-in made the permission matrix decorative. This applies ONLY where the org has configured nothing for the role, and an org that deliberately sets Schedules to `none` gets `none`. Caught by a test that already existed: the baseline as a floor overrode a configured `none` and re-created the defect one commit after removing it. */ const hasConfiguredProfile = organizationRoleCodings .some((roleCoding) => getOrganizationRolePermissionProfile(organization, roleCoding) !== undefined); const baselineLevel: StaffPermissionLevel = section === 'schedules' && organizationRoleCodings.length > 0 && !hasConfiguredProfile ? 'view' : 'none'; // For Generic workspace (builtInLevel === undefined), configured level is the only source. // For other built-in workspaces, configured permissions can supplement the built-in level. const resolved = builtInLevel !== undefined ? getHigherPermissionLevel(builtInLevel, configuredLevel) : configuredLevel; return getHigherPermissionLevel(resolved, baselineLevel); }; export const hasStaffPermission = ( level: StaffPermissionLevel, required: Exclude, ) => permissionLevelRank[level] >= permissionLevelRank[required]; const getWiderScope = ( first: StaffPermissionScope, second: StaffPermissionScope, ): StaffPermissionScope => (first === 'all' || second === 'all' ? 'all' : 'own'); /** * - WHOSE schedules may this caller see? The mirror of the server's `resolveSchedulesScope` * (`aws/sam/services/api/src/utils/staff-permissions.ts`). * * THE CLIENT NEEDS THIS BECAUSE A NARROWED SEARCH LOOKS EXACTLY LIKE AN EMPTY PRACTICE. `GET /Schedule` * for an `own`-scoped caller returns 200 with only their own blocks - no error, no marker - so a surface * asking *"what are everyone's hours?"* gets a truthful answer to a different question and renders it as * fact: *"this provider has no schedule"*, about a colleague who is working *. * Knowing the scope BEFORE asking is what lets a caller say *"I may not see this"* instead of *"there is * nothing there"*. * * IT AUTHORIZES NOTHING. The server narrows regardless of what this returns; a wrong answer here can * only produce a wrong SENTENCE, never wider data. MIRROR, NEVER A GATE. * * Keep the three rules below identical to the server's, and note that two of them look alike and are * opposites: * - **no org-role coding at all `all`.** They were granted by MASTER ROLE (an Admin), who carries no * profile. Scope narrows a grant already held; it never creates one. * - **a coding with NO configured profile `own`.** An org that has granted nothing must not hand out * everyone's availability by default - that is the opposite of what ruled. * - **a configured profile with NO scope `all`.** That is a profile SAVED BEFORE the scope axis existed, * and reading it as `own` would silently narrow a grant the org already made, including HR's. */ export const resolveSchedulesScope = ({ organization, practitionerRole, practitionerRoles, }: { organization?: Organization | null; practitionerRole?: PractitionerRole; practitionerRoles?: PractitionerRole[]; }): StaffPermissionScope => { const rolesToEvaluate = practitionerRoles?.length ? practitionerRoles : practitionerRole ? [practitionerRole] : []; const held = rolesToEvaluate .flatMap((role) => getPractitionerRoleOrganizationCodings(role)) .filter((coding) => Boolean(getCodingKey(coding))); if (held.length === 0) return DEFAULT_SCHEDULES_SCOPE; const profileByRole = new Map( getOrganizationRolePermissionProfiles(organization) .map((profile) => [getCodingKey(profile.role), profile] as const), ); /* THE FRONT DESK IS THE EXCEPTION, AND IT WAS MISSING FROM BOTH SIDES. ruling: *"Receptionist KEEPS its `view` floor: the front desk must see everyone's availability to book at all."* That floor is on the LEVEL (`getBuiltInPermissionLevel` above) and this axis had none - so a receptionist at a practice whose admin had never opened the permission matrix resolved to `own`, their Schedule search came back narrowed to blocks they do not have, and **the front desk could see no provider availability at all.** Every new practice starts in exactly that state. KEYED ON WHETHER ANY OF THEIR ROLES GRANTS THE RECEPTIONIST WORKSPACE, which is precisely how the Cognito group the SERVER checks is derived (`getPractitionerRoleAccessGroups` in the pre-token trigger). NOT on the role CODE - a custom "Front Office" role granting that workspace would be missed - and not on the ACTIVE workspace, which is a fact about what they are looking at now and would disagree with the server the moment they switched tabs. */ const isFrontDesk = rolesToEvaluate.some( (role) => role.active !== false && getPractitionerRoleWorkspaces(role).includes(STAFF_WORKSPACES.RECEPTIONIST), ); const unconfigured: StaffPermissionScope = isFrontDesk ? 'all' : 'own'; /* RESOLVED PER ROLE, not once for the whole caller. Someone holding a configured clinical role AND an unconfigured receptionist one used to be answered by the configured role alone, so the front-desk floor would never have reached them. And a CONFIGURED choice still beats the floor - own distinction, pointing the other way: a floor raises what an org has not set, it does not override what an org has. */ return held.reduce((widest, coding) => { const profile = profileByRole.get(getCodingKey(coding)); const roleScope = profile ? (profile.schedulesScope ?? DEFAULT_SCHEDULES_SCOPE) : floorManagesSchedules(coding) ? 'all' : unconfigured; return getWiderScope(widest, roleScope); }, 'own'); }; /** * MAY THIS PERSON OPEN THE PRACTITIONER WORKSPACE? * * Spencer, 2026-09-03: *"Any user with a practitioner or allied health role should have access to the * practitioner workspace by default. The org settings to give permission to roles to access the * practitioner workspace or not should be built along with the other existing workspace -> roles * settings."* * * THIS IS NOT THE SEAT, AND THE WHOLE RULING IS THAT THEY ARE SEPARATE. Three independent grants: * the ORGANIZATION ROLE says what you are, THIS says whether the door opens, and the paid CLINICIAN * SEAT says whether you may see a patient once inside. A medical assistant may room patients without * costing the practice $50 - which is what the product does today and why this exists. * * THE DEFAULT READS AN EXISTING CLASSIFICATION RATHER THAN INVENTING ONE, but not the obvious * candidate: `config/staffRoles.ts` and its `OrganizationRoleFamily`. Measured * 2026-09-03, that module is a PARALLEL catalogue on a different code system * (`.../organization-roles`), nothing imports its family type, and no live role coding carries the * field. Defaulting on it would have made every role answer the same and looked like a working * default until somebody checked one. * `CLINICAL_ORGANIZATION_ROLE_CODES` is the live equivalent, derived from the `patientFacing` groups * of the catalogue `staffAccess` actually matches codings against. Its split IS Spencer's: eleven * clinical groups true, Administrative & Operations the only one false. */ export const roleMayOpenPractitionerWorkspace = ({ organization, practitionerRole, practitionerRoles, }: { organization?: Organization | null; practitionerRole?: PractitionerRole | null; practitionerRoles?: PractitionerRole[] | null; }): boolean => { const rolesToEvaluate = practitionerRoles?.length ? practitionerRoles : practitionerRole ? [practitionerRole] : []; const organizationRoleCodings = Array.from( new Map( rolesToEvaluate .flatMap((role) => getPractitionerRoleOrganizationCodings(role)) .map((coding) => [getCodingKey(coding), coding] as const), ).values(), ); return codingsMayOpenPractitionerWorkspace(organization, organizationRoleCodings); }; /** * The same question asked of raw org-role CODINGS rather than of PractitionerRole resources. * * IT EXISTS BECAUSE THE ADMIN SCREENS DO NOT HOLD A ROLE YET. Somebody assigning workspaces on the * create form has picked codings in a form field and there is no resource to read; the Workspaces tab * has several roles and the union of their codings is what the token mint will see. Both must ask this * exactly as the resolver above does, or the checkbox and the token disagree - which is the whole of * the card this was written for. */ export const codingsMayOpenPractitionerWorkspace = ( organization: Organization | null | undefined, organizationRoleCodings: Coding[], ): boolean => { // NO CODINGS AT ALL MEANS YES, AND THE OPPOSITE WOULD BE A REGRESSION RATHER THAN A SAFE DEFAULT. // This setting is keyed on a role CODING, so somebody carrying none is a person the org has had no way // to configure - and the workspace on their record was still put there by an admin assigning it. // Reading that as a refusal would take the practitioner workspace away from every legacy role carrying // only the old `cognitoGroup` meta.tag, which on staging is most of them. // The grant can WITHDRAW a workspace only when the org has spoken about a role the person actually // holds; it never has to create one, because the assignment already did that. // AND IT MUST MATCH THE SERVER EXACTLY - `auth`'s `practitionerWorkspaceGrant.ts` answers this same // question at token mint. The two disagreeing is a nav that offers a workspace the token will not // carry, which is the defect class this gate exists inside. if (organizationRoleCodings.length === 0) { return true; } // ANY role grants it, matching how every other permission here resolves - `resolveStaffPermissionLevel` // takes the HIGHEST level across a person's roles rather than the lowest. Somebody who is both a nurse // and a receptionist is a nurse when they are nursing, and taking the minimum would strip a grant they // legitimately hold because of a second hat. return organizationRoleCodings.some((coding) => { const configured = getOrganizationRolePermissionProfile(organization, coding)?.practitionerWorkspace; // The org has spoken about this role - in either direction. An explicit `false` is a withdrawal // and must beat the default, which is the entire point of having the setting. if (configured !== undefined) { return configured; } return Boolean(coding.code && CLINICAL_ORGANIZATION_ROLE_CODES.has(coding.code)); }); }; /** * Whether these organization roles reach Lab and imaging work, read the way `resolveStaffPermissionLevel` * reads it for a tech workspace. It explains an empty workspace on the form; it authorizes nothing. */ export const codingsReachDiagnostics = ( organization: Organization | null | undefined, organizationRoleCodings: Coding[], ): boolean => organizationRoleCodings.some((coding) => { const configured = getOrganizationRolePermissionProfile(organization, coding)?.permissions.diagnostics || 'none'; return hasStaffPermission(getHigherPermissionLevel(configured, getRoleDefaultPermissionLevel(coding, 'diagnostics')), 'view'); }); /** * The workspaces a role actually OPENS, which is not the same as the workspaces it was assigned. * * IT LIVES HERE RATHER THAN IN `clinicianEnabled.ts` BECAUSE IT IS NO LONGER ABOUT THE SEAT. Spencer, * 2026-09-03: *"Anyone with any role could technically room patients as long as the org has given that * role permission to access the practitioner workspace... only the actual encounter aspect of the * practitioner's workspace is gated to a paid clinician seat."* The org's per-role grant opens the door; * `clinician-enabled` decides what may be done once inside and is enforced at request time. * * IT MIRRORS THE SERVER RATHER THAN INVENTING A RULE. `auth`'s `practitionerWorkspaceGrant.ts` answers * the same question at token mint and strips the `Practitioners` access group when the answer is no. The * two disagreeing is the original defect one layer along: the switcher offered a workspace the token did * not carry, a full chart rendered, and the first clinical write came back as a raw IAM denial. * An absent door reads as "I do not have that job". A door that opens and then refuses reads as "this * software is broken" - which is why hiding it is the fix and a better error message is not. * * IT REMOVES THIS ONE WORKSPACE AND NO OTHER, exactly as the server does. A gate that dropped anything * else would take the access of every receptionist, admin, HR and billing person in a single deploy. * * THE ORGANIZATION IS CONSULTED ONLY FOR A ROLE THAT BELONGS TO IT. One person can hold roles at several * practices while the client holds one practice's record at a time, so applying this org's withdrawal to * another practice's role would strip a workspace that practice granted. An unmatched role falls back to * its own classification, which is the same answer an unconfigured org gives. */ export const applyPractitionerWorkspaceGate = ( workspaces: StaffWorkspace[], role: PractitionerRole | undefined, organization: Organization | null | undefined, ): StaffWorkspace[] => { if (!workspaces.includes(STAFF_WORKSPACES.PRACTITIONER)) { return workspaces; } const governing = organization?.id && role?.organization?.reference === `Organization/${organization.id}` ? organization : undefined; if (roleMayOpenPractitionerWorkspace({ organization: governing, practitionerRole: role })) { return workspaces; } return workspaces.filter((workspace) => workspace !== STAFF_WORKSPACES.PRACTITIONER); };