import { useEffect, useState } from "react"; import { Hospital, UserRound } from "lucide-react"; import { usePractitionerServiceContext } from "../../../services"; import type { MemberClass } from "../../../services"; import { formatFhirHumanName } from "../../../utils/fhirHumanName.tsx"; import { useRelatedPersonIdentity } from "../../../hooks/useRelatedPersonIdentity.tsx"; import { PersonBadge } from "../../../components/ui/person-badge.tsx"; // Resolves a Practitioner's memberRef ("Practitioner/{id}") to a human-readable name. Falls back to // the raw reference on a failed/slow lookup rather than blocking or crashing the row - mirrors // OrgStaffSection's swallow-and-degrade pattern (fetchPractitioners(orgId).catch(() => {})) since a // single bad lookup shouldn't take down the whole members list. // - THE RESOLUTION OUTCOME, exposed rather than flattened straight into JSX. // // A members list may degrade to the raw reference; a CONSENT DIALOG may not. Both used to share the // label component, which returns a ReactNode - so by the time the identity reached the dialog it was // already a rendered string, and the dialog had no way to tell a real name from the fallback. It // therefore asked the patient to grant access to a UUID, and offered a button to do it. // // So the resolution is a hook and the CALLER decides what an unresolved name means. The label keeps // degrading, because a raw reference in a list row is untidy and nothing more. The dialog refuses. // // The failure is a normal state here rather than an exception: a patient may only resolve // practitioners inside their own organization, and an external collaborator is by definition outside // it. Measured on staging 2026-08-22 - three team members answered 403 while the in-org one resolved, // same screen, same session, same route. export type MemberIdentityStatus = "loading" | "named" | "unidentified"; export const usePractitionerIdentity = ( id: string | undefined, /** * A name the SERVER already resolved (, server half). When present nothing is fetched - and * it is the only path that works for an external practitioner, whom the client cannot look up at all. * The client fetch remains for members inside the caller's own organization. */ suppliedName?: string, ): { status: MemberIdentityStatus; name: string | null } => { const { fetchPractitioner } = usePractitionerServiceContext(); const [name, setName] = useState(null); const [loading, setLoading] = useState(true); // NOT an early return above the effect: that would change the hook count between renders the // moment a supplied name arrives. The effect skips instead, and the result is chosen at the end. const shouldFetch = !suppliedName && Boolean(id); useEffect(() => { let cancelled = false; setLoading(true); setName(null); // `!id` is checked alongside shouldFetch rather than relying on it, so the narrowing below is the // compiler's and not a comment's - shouldFetch already implies it, but TypeScript cannot see that. if (!shouldFetch || !id) { setLoading(false); return; } fetchPractitioner(id) .then(({ practitioner }) => { if (!cancelled) setName(formatFhirHumanName(practitioner.name) || null); }) .catch(() => { // Swallowed on purpose - an unresolvable practitioner is an expected state, not a fault. // What it MEANS is the caller's decision, which is the whole reason this returns a status. }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [id, fetchPractitioner, shouldFetch]); if (suppliedName) return { status: "named", name: suppliedName }; return { status: loading ? "loading" : name ? "named" : "unidentified", name }; }; /** * What to show for a member whose name genuinely cannot be resolved. * * A RAW FHIR REFERENCE IS NOT AN ANSWER TO A PATIENT. "RelatedPerson/0357ad5b-b959-4a34-9911- * d585d14502e6" is 45 characters that tell them nothing, on the screen where they choose who may read * their record and each row has a Remove button. * * AND THE SHORT ID IS DELIBERATE, not decoration. A bare "Caregiver (name unavailable)" reads * better but makes two unresolved members INDISTINGUISHABLE - which matters precisely here, because * the next thing the patient does is remove one of them. The fragment is the smallest thing that * keeps the rows apart while leading with a word rather than a resource type. * * This is the degraded path, never the normal one. It fires when the identity projection returns * empty - measured on staging as a legacy row whose projection answers `{}` while its siblings * resolve normally. */ const unresolvedLabel = (kind: string, memberRef: string): string => { const id = memberRef.split("/")[1] ?? ""; const fragment = id.split("-")[0]; return fragment ? `${kind} ยท ${fragment}` : kind; }; const PractitionerIdentityLabel = ({ id, memberRef }: { id: string | undefined; memberRef: string }) => { const { status, name } = usePractitionerIdentity(id); return <>{status === "loading" ? "Loading..." : name || unresolvedLabel("Practitioner", memberRef)}; }; // Resolves a RelatedPerson's memberRef ("RelatedPerson/{id}") to a human-readable name via the // shared `useRelatedPersonIdentity` projection hook (RelatedPerson re-foundation, Slice A Phase 1: // the resource itself is a thin link with no name on it anymore). Same graceful-degrade contract as // the practitioner branch - a failed/empty projection falls back to the raw memberRef, never blank // or crashing. const RelatedPersonIdentityLabel = ({ id, memberRef }: { id: string | undefined; memberRef: string }) => { const { name, loading } = useRelatedPersonIdentity(id); const resolvedName = formatFhirHumanName(name, { emptyText: '' }); return <>{loading ? "Loading..." : resolvedName || unresolvedLabel("Caregiver", memberRef)}; }; // Resolves a collaboration-team member's memberRef ("Practitioner/{id}" or "RelatedPerson/{id}") to // a human-readable name, shared by TeamMembersSection + PendingApprovalsSection so both render the // same identity. Dispatches to the class-specific label component below rather than branching // inside a single hook call, so each branch can call its own resolver hook unconditionally (rules // of hooks) instead of conditionally invoking one hook or the other from a single component body. export const MemberIdentity = ({ memberRef, memberClass, inviteEmail, displayName, }: { memberRef: string; memberClass: MemberClass; /** S1b - the invited address, for a pending `external-practitioner` row. */ inviteEmail?: string; /** * The name the SERVER already resolved for this member, when it could. * * PREFERRED OVER THE LOOKUP BELOW, and the reason is in the field's own declaration: a patient may * only read practitioners inside their own organization, and a collaboration team is deliberately * CROSS-ORG - so the members they most need named are exactly the ones their own token can never * fetch. Without this the list rendered `Practitioner/` beside a Remove button, on the screen * where a patient decides who may read their entire record. * * Absent means UNRESOLVED, never "no name" - so an absent value falls through to the lookup * rather than rendering blank. */ displayName?: string; }) => { const id = memberRef.split("/")[1]; const isExternal = memberClass === "external-practitioner"; const Icon = memberClass === "related-person" ? UserRound : Hospital; // An external invitee has NO resource to resolve - their memberRef is `external:`, // not a reference. The previous two-branch form sent them down the RelatedPerson path, which tried // to fetch a RelatedPerson whose id is a hash. Show the address they were invited at instead; it // is the only identity that exists until they accept. if (isExternal) { return ( {inviteEmail ?? "Invited by email"} ); } // The server already answered, so do not ask again. Beyond rendering the right name this also stops // the doomed request: the lookup below is a `GET /R4/Practitioner/{id}` that a patient's token cannot // make for anybody outside their organization, so it 403s and lands three errors in the console on // every visit to this screen. // // AND THIS IS THE ONE BRANCH THAT CAN DRAW THE PERSON'S OWN MARK, because it is the one that has // their name before it renders. The deck leads every member row with two initials; the generic // icon it replaces here was identical on every row, so a roster of seven opened with seven // identical shapes. The other branches keep the icon - initials cannot be derived from a name // that has not resolved, and inventing one from the reference would be a mark that means nothing. if (displayName) { return ( {displayName} ); } return ( {memberClass === "practitioner" ? ( ) : ( )} ); }; export default MemberIdentity;