import { useEffect, useMemo, useRef, useState } from 'react'; import { usePractitionerServiceContext } from '../services/PractitionerService'; import { usePatientServiceContext } from '../services/PatientService.tsx'; import { useRelatedPersonServiceContext } from '../services/RelatedPersonService'; import { formatFhirHumanName } from '../utils/fhirHumanName'; import { parseParticipantReference } from '../components/video/participantIdentity'; /** * Resolve a display name for a set of FHIR references (`Practitioner/...`, `Patient/...`, * `RelatedPerson/...`) - for surfaces the server hands refs and a class, and no names. * * THE DEFECT THIS EXISTS FOR. The visit-invitation panel asked a receptionist *"who should join this * visit?"* and rendered **four checkboxes labelled with raw 36-character UUIDs**, every one with the * same secondary label ("Clinician"). It was not a display glitch - it made the control unusable for * the one thing it is for, and a screen-reader user heard the UUID read out digit by digit. Two of the * four ids were named correctly **elsewhere on the very same dialog**. * * THE RULE WAS ALREADY WRITTEN DOWN AND THIS SURFACE NEVER ADOPTED IT. * `components/video/participantIdentity.ts` says, in capitals, *NEVER RENDER THE RAW REFERENCE* - * *"it means nothing to a patient and leaks an internal id into a room a related person may be sitting * in"* - and owns the fallback chain that ends in a real name or a PHI-free label. That module is * imported here rather than re-derived, because the RULES are the half that drifts. * * THE THIRD RESOLVER, NAMED RATHER THAN QUIETLY ADDED. `components/video/useParticipantNames.ts` * does the same fetching for the video roster, and `hooks/useAppointmentPatientNames.ts` does it for * patients. This is a third copy of the *plumbing* and that is a real cost - it is written this way * because the video hook is another session's live lane and folding it in would mean editing it. when * that lane is next open, `useParticipantNames` should delegate here, exactly as * `useAppointmentPatientNames` delegates to `usePatientNamesByIds`. The shared **rules** already live in * one place, which is the part that matters. * * PHI: resolved names live in the caller's React state only - never storage - and are re-fetched on * every mount, exactly as the video roster and the appointment resolver do. */ /** `undefined` = never attempted - `null` = attempted, nothing usable came back - string = the name. */ type ResolvedName = string | null; export interface ReferenceNameResolution { /** ref -> a resolved, non-empty name. A ref absent here has no usable name. */ names: Record; /** * Refs we have finished trying. * * THIS IS WHAT SEPARATES "still asking" FROM "asked and got nothing", and the caller must render * those differently. Collapsing them is how a row sits on a permanent placeholder, or announces an * unknown before anyone has looked. */ attempted: Set; } const REF_KEY_DELIMITER = ','; export function useFhirReferenceNames( refs: readonly (string | undefined)[], /** Whose reads these are (a viewer). A new scope drops every name and attempt of the last one, and its late answers. */ scope?: string | number, ): ReferenceNameResolution { const { fetchPractitioner } = usePractitionerServiceContext(); const { fetchPatient } = usePatientServiceContext(); const { fetchRelatedPerson } = useRelatedPersonServiceContext(); const [resolved, setResolved] = useState>({}); // Synchronous, so several rows naming one person in the same tick cannot each start a fetch. It is // also the only thing that stops a ref being re-read: once here it stays here for the hook's life. const attemptedRef = useRef>(new Set()); const scopeRef = useRef(scope); const [scopedFor, setScopedFor] = useState(scope); if (scopedFor !== scope) { setScopedFor(scope); setResolved({}); attemptedRef.current = new Set(); scopeRef.current = scope; } // A STRING key rather than the array: the caller's list is a fresh array on every render, and // depending on the reference would re-run this effect on every unrelated redraw. const refsKey = useMemo( () => Array.from(new Set(refs.filter(Boolean) as string[])).sort().join(REF_KEY_DELIMITER), [refs], ); useEffect(() => { const toFetch = refsKey .split(REF_KEY_DELIMITER) // ⛔ Anything that is not a genuine `Type/id` is not fetched and not guessed at. It also // makes the non-null assertion below sound. .filter((ref) => ref && !attemptedRef.current.has(ref) && parseParticipantReference(ref)); if (toFetch.length === 0) return; toFetch.forEach((ref) => attemptedRef.current.add(ref)); const readFor = scopeRef.current; void (async () => { const results = await Promise.all( toFetch.map(async (ref): Promise => { const parsed = parseParticipantReference(ref)!; try { if (parsed.type === 'Practitioner') { const { practitioner } = await fetchPractitioner(parsed.id); return [ref, formatFhirHumanName(practitioner?.name, { emptyText: '' }) || ''] as const; } if (parsed.type === 'Patient') { const patient = await fetchPatient(parsed.id); return [ref, formatFhirHumanName(patient?.name, { emptyText: '' }) || ''] as const; } if (parsed.type === 'RelatedPerson') { const relatedPerson = await fetchRelatedPerson(parsed.id); return [ref, formatFhirHumanName(relatedPerson?.name, { emptyText: '' }) || ''] as const; } // No fetch exists for this type on this client - unresolvable, never invented. return [ref, null] as const; } catch { // A failed read is RECORDED, not retried. This panel renders in the patient // portal too, where a colleague's record may simply be refused - and a refusal // must settle into a stated unknown rather than a permanent placeholder or a // request repeated on every redraw. return [ref, null] as const; } }), ); // Applied by ref, unconditionally - `attemptedRef` hands each ref to exactly one run, so no // two runs can hold a result for the same ref and there is nothing for a later run to // clobber. A cancellation gate here has already shipped a real defect in the video roster's // version of this loop: the superseded run discarded a result the id could never // be retried for, stranding that participant on the fallback for the whole call. // Except across a change of scope: the next scope reads afresh, so an answer for the last one is dropped. if (scopeRef.current !== readFor) return; setResolved((previous) => { const next = { ...previous }; for (const [ref, name] of results) next[ref] = name; return next; }); })(); }, [refsKey, scope, fetchPractitioner, fetchPatient, fetchRelatedPerson]); return useMemo(() => { const names: Record = {}; const attempted = new Set(); for (const [ref, name] of Object.entries(resolved)) { attempted.add(ref); if (name) names[ref] = name; } // A ref that is not a parseable reference is never fetched, so it would otherwise sit in // "still asking" forever. It is settled immediately: there is nothing to wait for. for (const ref of refs) { if (ref && !parseParticipantReference(ref)) attempted.add(ref); } return { names, attempted }; }, [resolved, refs]); }