import type { Attachment, Organization, PractitionerRole } from "fhir/r4"; import { formatSingleAddress } from "./fhirAddress.tsx"; import { formatPhoneNumber } from "./phoneNumber.tsx"; import { formatFhirHumanName } from "./fhirHumanName.tsx"; import { ORG_LOGO_EXTENSION_URL } from "../config/organizationExtensions"; import type { PractitionerWithRoles } from "../services"; /** * The Providers tab's list: one group per practice the patient belongs to, holding the people there * they can message. * * WHY THIS IS A FUNCTION AND NOT A `useMemo` IN THE PAGE. It used to be one, inside a component of * fifteen hundred lines behind a dozen contexts, and the consequence was that the only tests anybody * could write about this list read the page's SOURCE TEXT. A source read cannot tell a rule that works * from a rule that is merely written down: it was green throughout the defect below. * * THE DEFECT IT EXISTS TO FIX. The list was built from the messageable practitioners alone, so a * practice with none formed no group at all. Pressing "Message " on that practice's card in * My providers navigates here with its id, and there was nothing to expand - so the patient asked to * message one practice, was shown a different practice's people under the branding of the one they are * signed into, and nothing on the screen said so. Every practice the patient belongs to now forms a * group whether or not anybody there can be messaged. * * IT IS AN ABSENCE, NOT A DENIAL. Nothing is refused: the patient belongs to the practice and is * entitled to message it, there is simply nobody there yet. `hasMessageablePeople` is what the screen * reads to say so, and it is deliberately NOT `practitioners.length === 0` - see below. * * AND THE DETAILS COME FROM THE PATIENT'S OWN ORGANIZATION LIST. Resolving them from the practice the * patient is standing in is what left every other practice with no address, no phone, no logo and the * literal word "Organization" for a name. */ export interface PatientProviderGroup { id: string; name: string; address?: string; phone?: string; logoAttachment?: Attachment; /** The people who survived the search, which is what the group lists. */ practitioners: PractitionerWithRoles[]; /** * Whether this practice has ANYONE the patient can message, before the search is applied. * * NOT `practitioners.length > 0`, and the difference is the whole reason this field exists: with a * search term typed, a practice that is full of people can have none of them match, and a group * that then said "No one here to message yet" would be telling the patient something false about * their practice on the strength of what they typed. */ hasMessageablePeople: boolean; } interface BuildArguments { /** Every practice the patient belongs to, from `useMyOrganizations`. */ organizations: Organization[]; /** Every messageable practitioner; the search is applied here, per practice. */ allPractitioners: PractitionerWithRoles[]; /** The practice the patient is signed into, as a last resort for details. */ currentOrganization?: Organization | null; searchTerm: string; /** `Reference` -> id, which the page holds as a context helper. */ organizationIdOf: (reference: PractitionerRole["organization"]) => string | undefined; } const detailsOf = (organization: Organization | undefined) => ({ address: organization?.address?.[0] ? formatSingleAddress(organization.address[0]) : undefined, // FORMATTED, BUT WITHOUT THE `use` SUFFIX, and the difference is the drawing. A practice stores // whatever it typed, so one reads "319-768-1042" and another "6085550142", and taking the raw value // put a run of ten digits in front of a patient on the one screen that offers to call it. // // `formatFhirContactPoint` is the helper the practice switcher reads and it appends the contact's // `use` - "(608) 555-0142 (work)". Walked on the mount and it is wrong here: the deck draws this // action as "Call (319) 555-0188", a number and nothing else, and a parenthesised "(work)" inside a // button label is noise on the one control a patient presses to phone their practice. // `formatPhoneNumber` is the half of that helper that does the formatting. phone: (() => { const first = organization?.telecom?.find( (contact) => contact.system === "phone" && contact.value?.trim(), )?.value?.trim(); return first ? formatPhoneNumber(first) : undefined; })(), logoAttachment: organizationLogoOf(organization), }); /** * A practice's own mark, wherever a caller has the Organization in hand. * * Exported because the patient's inbox needs the same answer for the front-desk row and the URL of * that extension is the kind of fact that goes wrong quietly when it is written down twice: the second * copy keeps working right up until the first one moves. */ export const organizationLogoOf = (organization?: Organization | null) => organization?.extension?.find( (extension) => extension.url === ORG_LOGO_EXTENSION_URL, )?.valueAttachment; const activeRolesOf = (practitionerWithRoles: PractitionerWithRoles): PractitionerRole[] => (practitionerWithRoles.roles || []).filter((role) => role.active !== false); export const buildPatientProviderGroups = ({ organizations, allPractitioners, currentOrganization, searchTerm, organizationIdOf, }: BuildArguments): PatientProviderGroup[] => { const organizationById = new Map( organizations .filter((organization) => !!organization.id) .map((organization) => [organization.id as string, organization]), ); // Computed from the UNFILTERED list, so a search can empty a group's rows without changing what // the group is allowed to say about itself. const organizationIdsWithPeople = new Set(); allPractitioners.forEach((practitionerWithRoles) => { activeRolesOf(practitionerWithRoles).forEach((role) => { const id = organizationIdOf(role.organization) || role.organization?.display?.trim(); if (id) organizationIdsWithPeople.add(id); }); }); const groups = new Map(); organizations.forEach((organization) => { const organizationId = organization.id; if (!organizationId) return; groups.set(organizationId, { id: organizationId, name: organization.name?.trim() || organizationId, ...detailsOf(organization), practitioners: [], hasMessageablePeople: organizationIdsWithPeople.has(organizationId), }); }); allPractitioners.forEach((practitionerWithRoles) => { activeRolesOf(practitionerWithRoles).forEach((role) => { const organizationId = organizationIdOf(role.organization); const organizationName = role.organization?.display?.trim(); if (!organizationId && !organizationName) return; // A clinician's other practice is not the patient's practice. if (organizationById.size > 0 && !(organizationId && (organizationById.has(organizationId) || currentOrganization?.id === organizationId))) return; const groupId = organizationId || organizationName || "unknown-org"; // The patient's own organization list first, the practice they are standing in second. const resolvedOrg = (organizationId ? organizationById.get(organizationId) : undefined) ?? (organizationId && currentOrganization?.id === organizationId ? currentOrganization : undefined) ?? undefined; const details = detailsOf(resolvedOrg); const existingGroup = groups.get(groupId); if (!existingGroup) { groups.set(groupId, { id: groupId, name: resolvedOrg?.name?.trim() || organizationName || organizationId || "Organization", ...details, practitioners: [practitionerWithRoles], hasMessageablePeople: true, }); return; } const alreadyIncluded = existingGroup.practitioners.some( (item) => item.practitioner.id === practitionerWithRoles.practitioner.id, ); if (!alreadyIncluded) existingGroup.practitioners.push(practitionerWithRoles); existingGroup.hasMessageablePeople = true; if (!existingGroup.address) existingGroup.address = details.address; if (!existingGroup.phone) existingGroup.phone = details.phone; if (!existingGroup.logoAttachment) existingGroup.logoAttachment = details.logoAttachment; }); }); // The people keep the name the patient reads as their order. Arrival order came from the polled staff // list and differed from one load to the next, so the same practice reshuffled its people. const nameOf = (practitionerWithRoles: PractitionerWithRoles) => formatFhirHumanName(practitionerWithRoles.practitioner.name); const groupsArray = Array.from(groups.values()) .map((group) => ({ ...group, practitioners: [...group.practitioners].sort((a, b) => nameOf(a).localeCompare(nameOf(b))), })) .sort((a, b) => a.name.localeCompare(b.name)); if (!searchTerm.trim()) return groupsArray; const normalizedSearch = searchTerm.trim().toLowerCase(); // Every practice stays listed: a practice that matches keeps its people, any other keeps the ones who match. return groupsArray.map((group) => { const practiceMatches = [group.name, group.address, group.phone] .some((field) => !!field && field.toLowerCase().includes(normalizedSearch)); if (practiceMatches) return group; return { ...group, practitioners: group.practitioners.filter((practitionerWithRoles) => { const practitionerName = formatFhirHumanName(practitionerWithRoles.practitioner.name)?.toLowerCase(); return !!practitionerName && practitionerName.includes(normalizedSearch); }), }; }); };