import { HealthcareService, Practitioner, PractitionerRole } from "fhir/r4"; import { getOrganizationRoleGroupForCode, getPractitionerRoleOrganizationCodings, } from "./staffRoleCatalog"; // A staff member as loaded by useFhirPractitioners(). export interface PractitionerWithRoles { practitioner: Practitioner; roles: PractitionerRole[]; } export interface LocationStaffMember { practitioner: Practitioner; roles: PractitionerRole[]; // True when this staff member is at the location only because their role is scoped to // "All locations" (an empty PractitionerRole.location), i.e. they are org-wide, not assigned // to this specific site. Counted at every location by design, but flagged so the UI can label it. orgWide: boolean; } export interface LocationRoleAssignment { code: string; display: string; staff: LocationStaffMember[]; } export interface LocationCategoryAssignment { // ORGANIZATION_ROLE_GROUPS label (e.g. "Medical - Physicians"), or "Other" for custom roles. label: string; roles: LocationRoleAssignment[]; // Distinct staff across all roles in this category at the location. staffCount: number; } // A service as it applies to a given location. Mirrors LocationStaffMember: an org-wide service (a // HealthcareService with no `location`, i.e. "All locations") is listed at every location but flagged as // orgWide so the UI can label it rather than show it as if it were assigned to this specific site. export interface LocationServiceAssignment { service: HealthcareService; orgWide: boolean; } const OTHER_CATEGORY_LABEL = "Other"; /** * Card 256 — "this person covers every site", stated EXPLICITLY. * * THE DEFECT THIS EXISTS TO CLOSE: one state was doing two jobs. An empty `PractitionerRole.location` * meant both *"nobody has set this"* and *"this person genuinely covers every site"*, and the system * resolved that ambiguity in the MOST PERMISSIVE direction - so a value nobody ever set, cleared by a * partial save, or dropped by a migration produced MAXIMUM visibility rather than none. * It is about to matter more: the front-desk pool routes patient messages BY LOCATION, so * under the old reading a receptionist with no location would receive every site's patient messages. * * **Never implement "all locations" by enumerating every Location reference.** It looks equivalent and * is not: a staff member enumerated across today's sites silently STOPS covering a site added tomorrow. * That is the frozen-snapshot bug this project has already paid for twice */ export const STAFF_ALL_LOCATIONS_EXTENSION_URL = "https://akashichealthrecords.com/fhir/StructureDefinition/practitioner-role-all-locations"; /** * Is this role scoped to every site in the org? * * THIS IS THE ONE ANSWER, and collecting it here is half the fix. The "empty means everywhere" rule was * independently re-derived in four places - the locations screen, the staff filter, and both booking * surfaces — which is card 235's *"every component derives its own what-counts"* root cause. Four copies * is four things to migrate, and migrating three of them is worse than migrating none. * * THE SECOND LINE IS THE MIGRATION SEAM, AND DELETING IT IS PHASE 3. Right now this returns the * IDENTICAL answer it always did, deliberately: an empty array is still org-wide, so shipping this * changes nothing for anybody. Once the backfill has stamped the extension on every empty role AND the * remaining count is measured at 0, the fallback line comes out and empty-with-no-extension becomes * invalid - rejected at the write path rather than silently granted everywhere. * * Do NOT remove the fallback before that count is verified 0. Every existing role would fail * validation on its next save, which makes staff records uneditable - the failure mode is a support call * per staff member, not a security gap. */ export const isOrgWideRole = (role?: PractitionerRole | null): boolean => { if (!role) return false; const stated = (role.extension ?? []).some( (extension) => extension.url === STAFF_ALL_LOCATIONS_EXTENSION_URL && extension.valueBoolean === true, ); if (stated) return true; // PHASE 3 DELETES THIS LINE, and nothing else. return (role.location ?? []).length === 0; }; /** * The location a newly-created staff member should be placed at when nobody chose one. * * Card 256, Spencer: *"until/unless a location is added or changed, every staff member should have the * primary location set as default. This prevents any staff member from having an empty location field."* * * SINGLE-SITE ONLY, AND THE LIMIT IS DELIBERATE. There is no "primary location" flag on an * Organization - nothing in the data says which site is the main one. With exactly one site there is * nothing to guess and this is simply correct, which is the case the ruling is about (*"the admin should * not have to think about it for a single-site practice - which is most of them"*). * * With SEVERAL sites this returns undefined rather than picking `[0]`, and that is the safer failure. * `[0]` is whatever order the server happened to return, so defaulting to it would put a staff member at * an arbitrary site - a silent WRONG answer, which is harder to notice and harder to undo than a missing * one. A multi-site org gets the explicit "who covers which site?" choice instead; until that picker * exists this is the known gap, and it is bounded by the org-setup gate that now requires at least one * location before staff can be created at all. * * Do NOT "improve" this by returning every site - see isOrgWideRole: enumerating today's locations is * the frozen-snapshot bug, and it is not what "primary" means anyway. */ export const defaultLocationForNewStaff = ( sites: { id?: string; name?: string }[], ): { reference: string; display?: string } | undefined => { if (sites.length !== 1) return undefined; const [site] = sites; if (!site.id) return undefined; return { reference: `Location/${site.id}`, ...(site.name ? { display: site.name } : {}) }; }; /** * The `{ location: [...] }` patch to spread into a NEW role, or `{}` when there is nothing to fill. * * Exists so a creation path expresses this in ONE call rather than testing and then calling - the * two-call form (`cond && f(x) ? { location: [f(x)] } : {}`) reads fine and is quietly worse: a mutation * that removed one call left the other behind, so the guard in * `__tests__/everyRoleCreateDefaultsLocation.test.ts` could not tell a live default from a dead one. * * FILLS A BLANK, NEVER OVERRIDES. A location the admin actually chose always wins; this only speaks * when the form said nothing at all. */ export const defaultRoleLocationPatch = ( chosen: { reference?: string }[] | undefined, sites: { id?: string; name?: string }[], ): { location?: { reference: string; display?: string }[] } => { if ((chosen?.length ?? 0) > 0) return {}; const fallback = defaultLocationForNewStaff(sites); return fallback ? { location: [fallback] } : {}; }; const referenceId = (reference?: string): string | undefined => reference?.split("/").pop(); // Whether a role places its practitioner at the given location. A role scoped to "All locations" counts // at every location (orgWide = true) - see isOrgWideRole for what that means and how it is migrating. const roleAtLocation = ( role: PractitionerRole, locationId: string, ): { at: boolean; orgWide: boolean } => { const locations = role.location ?? []; if (locations.length === 0) { // Fails CLOSED once the fallback in isOrgWideRole is removed: a role naming no site and not stating // that it covers every site places its practitioner NOWHERE, rather than everywhere. That direction // is the whole point of the card - but it only becomes reachable after the backfill, because until // then isOrgWideRole still answers true for an empty array. const orgWide = isOrgWideRole(role); return { at: orgWide, orgWide }; } const at = locations.some((loc) => referenceId(loc.reference) === locationId); return { at, orgWide: false }; }; interface RoleBucket { code: string; display: string; staff: Map; } interface CategoryBucket { label: string; roles: Map; staff: Set; } /** * Group the staff assigned to a location by role category, then by role. Org-wide ("All locations") * staff are included at every location and flagged via LocationStaffMember.orgWide. * Returns categories sorted by staff count (desc) then label, roles sorted by display. */ export const getLocationStaffByCategory = ( practitionersList: PractitionerWithRoles[], locationId?: string, ): { categories: LocationCategoryAssignment[]; staffCount: number } => { if (!locationId) return { categories: [], staffCount: 0 }; const categoryMap = new Map(); const allStaff = new Set(); for (const entry of practitionersList) { const practitionerId = entry.practitioner.id; if (!practitionerId) continue; for (const role of entry.roles) { const { at, orgWide } = roleAtLocation(role, locationId); if (!at) continue; const primaryCoding = getPractitionerRoleOrganizationCodings(role)[0]; const roleCode = primaryCoding?.code; if (!roleCode) continue; // a role with no org-role coding can't be categorized const roleDisplay = primaryCoding?.display || roleCode; const label = getOrganizationRoleGroupForCode(roleCode)?.label ?? OTHER_CATEGORY_LABEL; let category = categoryMap.get(label); if (!category) { category = { label, roles: new Map(), staff: new Set() }; categoryMap.set(label, category); } category.staff.add(practitionerId); allStaff.add(practitionerId); let roleBucket = category.roles.get(roleCode); if (!roleBucket) { roleBucket = { code: roleCode, display: roleDisplay, staff: new Map() }; category.roles.set(roleCode, roleBucket); } const existing = roleBucket.staff.get(practitionerId); // Prefer an explicit (site-specific) assignment over an org-wide one when both exist. if (!existing || (existing.orgWide && !orgWide)) { roleBucket.staff.set(practitionerId, { practitioner: entry.practitioner, roles: entry.roles, orgWide, }); } } } const categories: LocationCategoryAssignment[] = [...categoryMap.values()] .map((category) => ({ label: category.label, staffCount: category.staff.size, roles: [...category.roles.values()] .map((role) => ({ code: role.code, display: role.display, staff: [...role.staff.values()].sort((a, b) => practitionerSortKey(a.practitioner).localeCompare( practitionerSortKey(b.practitioner), ), ), })) .sort((a, b) => a.display.localeCompare(b.display)), })) .sort( (a, b) => b.staffCount - a.staffCount || a.label.localeCompare(b.label), ); return { categories, staffCount: allStaff.size }; }; const practitionerSortKey = (practitioner: Practitioner): string => { const name = practitioner.name?.[0]; if (!name) return practitioner.id ?? ""; return `${(name.family ?? "").toLowerCase()} ${(name.given?.join(" ") ?? "").toLowerCase()}`.trim(); }; /** * The services offered at the given location. A HealthcareService with no `location` is org-wide * ("All locations") -- the writer stores "all locations" by omitting the field * (HealthcareServiceService: `...(location.length !== 0 && { location })`), so an empty/absent * `location` means EVERYWHERE, not nowhere. Such services are included at every location and flagged * orgWide, exactly as roleAtLocation treats an empty PractitionerRole.location. A service listing this * location explicitly is included with orgWide=false. */ export const getServicesAtLocation = ( healthcareServicesList: HealthcareService[], locationId?: string, ): LocationServiceAssignment[] => { if (!locationId) return []; const assignments: LocationServiceAssignment[] = []; for (const service of healthcareServicesList) { const locations = service.location ?? []; if (locations.length === 0) { assignments.push({ service, orgWide: true }); } else if (locations.some((loc) => referenceId(loc.reference) === locationId)) { assignments.push({ service, orgWide: false }); } } return assignments; };