import { withRead, type ReadOptions } from '../hooks/anchorView/readOptions'; import { isHeld, overlayHeldBundle, refersToPatient, writtenSince } from '../utils/writeThroughHold'; import { useState, useCallback, createContext, useContext, ReactNode, useEffect, useRef } from 'react'; import { Bundle, CareTeam, CareTeamParticipant, Encounter, Organization, Patient, Reference } from 'fhir/r4'; import { environment } from '../environments/environment.ts'; import { useFhirServiceContext } from './FhirService.tsx'; import { useHttpService } from './HttpService.tsx'; import { formatFhirHumanName } from '../utils/fhirHumanName.tsx'; import { useEncounterServiceContext } from './EncounterService.tsx'; import { usePatientServiceContext } from './PatientService.tsx'; import { formatFhirPeriod } from '../utils/fhirPeriod.tsx'; import { applyArchive, isArchived, type ArchiveReason } from '../utils/archivedResource'; import { statusLabel } from '../utils/fhirStatusVocabulary'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; import { searchPageSize } from '../utils/pageSizeOverride'; import { useCacheOwner, type CacheOperation } from '../utils/cacheOwner'; import { SaveRetiredError } from '../utils/saveOperation'; import { asRecordChanged, ifMatch, PAGE_OUT_OF_DATE_MESSAGE, RecordChangedError } from '../utils/recordChanged'; /** * Who may change the shared list from outside a read: the read scope it was opened for (viewer, practice or * neutral, patient), the subject and practice its rows must belong to, and, for a reload that replaces the * list, the read operation it superseded. A writer whose subscription ended is retired. */ export type CareTeamListWriter = { scope: string; patientId?: string; organizationId?: string; operation?: CacheOperation; /** For a replacement, the list revision it was opened against; any publication since supersedes it. */ revision?: number; retired?: boolean; }; /** An accepted socket event: its full row, and whether it removes that row (deleted or archived). */ export type CareTeamListEvent = { resource: CareTeam; removed: boolean }; /** * The caller's own lifetime - its patient, practice and viewer as it began. Asked before anything starts, again * immediately before each request is sent, and before anything is published; once it answers false the work * stops there. */ export type CareTeamAdmission = { isLive: () => boolean; /** The caller writes from this answer, so an answer overtaken by a newer read is refused, not handed back. */ baseline?: boolean; }; /** A read a newer read overtook; its answer is not a baseline to write from. */ export class CareTeamReadOvertakenError extends Error { constructor() { super('A newer read of these care teams overtook this one.'); this.name = 'CareTeamReadOvertakenError'; } } const admitOnly = (admission?: CareTeamAdmission) => () => { if (admission && !admission.isLive()) throw new SaveRetiredError(); }; // Every request carries the practice its caller named, never whichever practice the session holds by the time // it is sent; a neutral read carries none. const transportFor = (organization: Organization | null | undefined, admission?: CareTeamAdmission) => ({ admit: admitOnly(admission), ...(organization?.id ? { organizationId: organization.id } : { bypassOrganization: true }), }); /** A write's own practice and lifetime, carried to the request header and the last check before it is sent. */ export type CareTeamTransport = { organization: Organization; admission: CareTeamAdmission }; type AcceptedChange = { revision: number; scope: string; row: CareTeam | null }; const API_URL = environment.fhirClinicalApi; export const useCareTeamService = () => { const { get, post, put, delete: del } = useHttpService(); const { patientState } = usePatientServiceContext(); const { transformToCodeableConcept, transformFromCodeableConcept, createReferenceObject, unpackBundle, addFhirPeriodIfApplicable, addPeriodControlsIfApplicable, transformToReferenceCollection, transformFromReferenceCollection, transformToTelecom, transformFromTelecom, extractReferenceId } = useFhirServiceContext(); const { getEncounterDisplay } = useEncounterServiceContext(); const [careTeamsList, setCareTeamsList] = useState([]); // The list as last published, held synchronously so a writer records exactly what it changed. const listRef = useRef([]); const putList = useCallback((next: CareTeam[]) => { listRef.current = next; setCareTeamsList(next); }, []); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [careTeamsBundle, setCareTeamsBundle] = useState | null>(null); const careTeamsBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); useEffect(() => { careTeamsBundleRef.current = careTeamsBundle; }, [careTeamsBundle]); // WHOSE RECORDS ARE THESE? The reset below fires only when there is NO patient, which moving // between two charts never is - so the previous patient's bundle survived the switch and was // served as a cache hit, with no request and nothing on screen to say the list was not theirs. // THE READ SCOPE THE LIST WAS PUBLISHED FOR: viewer, practice or neutral, and patient, as the caller // named it when the read began. A consumer shows the list only when this is the scope it renders. const ownedScopeRef = useRef(undefined); // Counts every accepted change to the list, from a read or a writer, so a replacement answer begun // before a newer one can tell that it is older. const revisionRef = useRef(0); // What writers changed in the list, by row and revision, so a read begun before a change keeps it. const acceptedRef = useRef(new Map()); const [ownedScope, setOwnedScope] = useState(undefined); const releaseScope = useCallback(() => { ownedScopeRef.current = undefined; acceptedRef.current.clear(); setOwnedScope(undefined); }, []); const cacheOwner = useCacheOwner('CareTeam', () => { releaseScope(); setLastUpserted(null); setCareTeamsBundle(null); putList([]); lastFetchTimeRef.current = 0; }); useEffect(() => { if (!patientState) { setCareTeamsBundle(null); putList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); releaseScope(); } }, [patientState, cacheOwner.release, releaseScope]); // No organization is the neutral host: the read is bound to the subject alone, and the gateway admits // it only for the caller's own record or a validated acting-as child. A practice is never inferred. const fetchCareTeams = useCallback(async (patient: Patient, organization?: Organization | null, admission?: CareTeamAdmission, options?: ReadOptions): Promise> => { return overlayHeldBundle('CareTeam', await fetchAllPagesAsBundle((pageParam) => { let params = new URLSearchParams(); params.append('_count', searchPageSize('100')); params.append('subject', patient.id as string); if (organization) params.append('organization', organization.id as string); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/CareTeam?${params.toString()}`, withRead(transportFor(organization, admission), options)); }), (resource) => refersToPatient(resource, patient.id) && (!organization || (resource.managingOrganization ?? []).some((ref) => ref.reference === `Organization/${organization.id}`))) as Bundle; }, [get]); const fetchCareTeamsWithPolling = useCallback(async (patient: Patient, organization?: Organization | null, forceReload = false, scope = '', admission?: CareTeamAdmission): Promise> => { const admit = admitOnly(admission); admit(); const now = Date.now(); const cacheAge = now - lastFetchTimeRef.current; const cacheFresh = cacheAge < 5 * 60 * 1000; // cacheOwner.isFor is the load-bearing clause everywhere else: a fresh bundle belonging to the // PREVIOUS patient satisfies every other condition and would be returned as a hit. // // HERE IT IS BELT AND BRACES, because this service has no working cache at all - NOTHING ever // sets `lastFetchTimeRef.current = now`, so `cacheFresh` is always false and this whole branch is // dead. It reads as a five-minute cache and is not one. Keyed anyway so that whoever adds the // missing stamp does not reintroduce the wrong-patient defect by doing so. if (!forceReload && !shouldReload && !writtenSince('CareTeam', lastFetchTimeRef.current) && careTeamsBundleRef.current && cacheFresh && ownedScopeRef.current === scope && cacheOwner.isCurrentFor(patient.id)) { return careTeamsBundleRef.current; } const operation = cacheOwner.begin(); const startedAt = revisionRef.current; let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: CareTeam[]; do { bundle = await fetchCareTeams(patient, organization, admission); entries = unpackBundle(bundle).filter((r) => !isArchived(r)); found = lastUpserted ? entries.some(e => e.id === lastUpserted) || isHeld('CareTeam', lastUpserted) : true; if (!found) { await new Promise(resolve => setTimeout(resolve, delay)); } retries--; } while (!found && retries > 0 && cacheOwner.publishes(operation) && (!admission || admission.isLive())); // A read whose caller's lifetime ended publishes nothing, even as the newest read. admit(); if (!cacheOwner.publishes(operation)) { if (admission?.baseline) throw new CareTeamReadOvertakenError(); return cacheOwner.answerFor(operation, bundle); } // Rows a writer changed after this read began are newer than its answer: the list, the stored bundle and // the answer handed back all carry them as they were accepted. for (const [id, change] of acceptedRef.current) { if (change.revision <= startedAt) acceptedRef.current.delete(id); } const newer = [...acceptedRef.current].filter(([, change]) => change.scope === scope); let resources = unpackBundle(bundle) as CareTeam[]; for (const [id, change] of newer) { resources = resources.filter((r) => r.id !== id); if (change.row) resources = [...resources, change.row]; } const answer: Bundle = newer.length === 0 ? bundle : { ...bundle, entry: resources.map((resource) => bundle!.entry?.find((e) => e.resource === resource) ?? { resource }), ...(bundle.total === undefined ? {} : { total: resources.length }), }; setCareTeamsBundle(answer); putList(resources.filter((r) => !isArchived(r))); setLastUpserted(null); setShouldReload(false); // Stamped WITH the data, never before it - on a throw the owner must still name the list that is // actually in state. cacheOwner.claim(patient.id, operation); ownedScopeRef.current = scope; revisionRef.current += 1; setOwnedScope(scope); return answer; }, [fetchCareTeams, unpackBundle, lastUpserted, shouldReload, putList]); // A reload replaces the list, so it supersedes any read in flight and is itself superseded by any later one. // A subscription only edits rows, so it opens without an operation and is retired when it unsubscribes. const openListWriter = useCallback((scope: string, patientId: string | undefined, organizationId: string | undefined, replacesList = false): CareTeamListWriter => ({ scope, patientId, organizationId, ...(replacesList ? { operation: cacheOwner.begin(), revision: revisionRef.current } : {}) }), [cacheOwner.begin]); // Lands only on the list published for the writer's scope, and only while the writer is current. A row the // writer adds or changes must be about its subject and, at a practice, managed by that practice - the same // rows the read itself would have returned. const writeCareTeamsList = useCallback((writer: CareTeamListWriter, update: (prev: CareTeam[]) => CareTeam[], event?: CareTeamListEvent): boolean => { if (writer.retired || !writer.scope || ownedScopeRef.current !== writer.scope) return false; if (writer.operation && !cacheOwner.publishes(writer.operation)) return false; if (writer.revision !== undefined && writer.revision !== revisionRef.current) return false; const belongs = (resource: CareTeam) => refersToPatient(resource, writer.patientId) && (!writer.organizationId || (resource.managingOrganization ?? []).some((ref) => ref.reference === `Organization/${writer.organizationId}`)); const revision = ++revisionRef.current; const prev = listRef.current; const next = update(prev).filter((resource) => prev.includes(resource) || belongs(resource)); const kept = new Set(next.map((r) => r.id)); for (const row of next) if (row.id && !prev.includes(row)) acceptedRef.current.set(row.id, { revision, scope: writer.scope, row }); for (const row of prev) if (row.id && !kept.has(row.id)) acceptedRef.current.set(row.id, { revision, scope: writer.scope, row: null }); // An event is recorded from its own row, so one about a row the visible list lacks still overtakes a held read. if (event?.resource.id && belongs(event.resource)) { acceptedRef.current.set(event.resource.id, { revision, scope: writer.scope, row: event.removed ? null : event.resource }); } putList(next); return true; }, [cacheOwner.publishes, putList]); // The cached row is served only to a caller naming the scope the list was published for. const fetchCareTeam = useCallback(async (id: string, scope?: string): Promise => { const cachedCareTeam = scope && ownedScopeRef.current === scope && cacheOwner.isCurrent(cacheOwner.claimed) ? careTeamsList.find(careTeam => careTeam.id === id) : undefined; if (cachedCareTeam) { return cachedCareTeam; } return get(`${API_URL}/CareTeam/${id}`); }, [get, careTeamsList]); const fetchCareTeamHistory = useCallback(async (id: string, options?: ReadOptions): Promise> => { return get>(`${API_URL}/CareTeam/${id}/_history`, options); }, [get]); const createCareTeam = useCallback(async (careTeam: CareTeam, transport?: CareTeamTransport): Promise => { let params = new URLSearchParams(); params.append('subject', extractReferenceId(careTeam.subject) as string); return post(`${API_URL}/CareTeam?${params.toString()}`, careTeam, transport && transportFor(transport.organization, transport.admission)); }, [post, extractReferenceId]); // CONDITIONAL: a care team is written and deleted only over basedOn, the version its editor read, never a // version echoed in the body. A refusal is a RecordChangedError: 412 another write landed first, 428 none named. const updateCareTeam = useCallback(async (careTeam: CareTeam, basedOn: string | undefined, transport?: CareTeamTransport): Promise => { if (!basedOn) throw new RecordChangedError(PAGE_OUT_OF_DATE_MESSAGE, true); return put(`${API_URL}/CareTeam/${careTeam.id}`, careTeam, { headers: ifMatch(basedOn), ...(transport && transportFor(transport.organization, transport.admission)) }) .catch((error: unknown) => { throw asRecordChanged(error); }); }, [put]); const deleteCareTeam = useCallback(async (careTeam: CareTeam, basedOn: string | undefined, transport?: CareTeamTransport): Promise => { if (!basedOn) throw new RecordChangedError(PAGE_OUT_OF_DATE_MESSAGE, true); return del(`${API_URL}/CareTeam/${careTeam.id}`, { headers: ifMatch(basedOn), ...(transport && transportFor(transport.organization, transport.admission)) }) .catch((error: unknown) => { throw asRecordChanged(error); }); }, [del]); const archiveCareTeam = useCallback( async (careTeam: CareTeam, reason: ArchiveReason, note?: string, transport?: CareTeamTransport): Promise => updateCareTeam(applyArchive(careTeam, reason, note), careTeam.meta?.versionId, transport), [updateCareTeam], ); const transformToFhirCareTeam = useCallback((item: any, patient: Patient, organization?: Organization, encounterState?: Encounter): CareTeam => { const { status, category, name, encounter, participant, reasonCode, reasonReference, telecom, note, meta } = item; const usCoreCareteamProfile: string = 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-careteam'; const patientReference: Reference = createReferenceObject('Patient', patient.id as string, formatFhirHumanName(patient.name)); const transformedTelecom = transformToTelecom(telecom) let careTeam: CareTeam = { resourceType: 'CareTeam', meta: { ...meta, profile: [usCoreCareteamProfile] }, subject: patientReference, ...(organization && { managingOrganization: [createReferenceObject('Organization', organization.id as string, organization?.name)] }), ...(encounterState && { encounter: createReferenceObject('Encounter', encounterState?.id as string, getEncounterDisplay(encounterState)) }), ...(encounter && { encounter: encounter }), ...(status && { status: status }), ...(category && category.length !== 0 && { category: transformToCodeableConcept(category) }), ...(name && { name: name }), ...(reasonCode && reasonCode.length !== 0 && { reasonCode: transformToCodeableConcept(reasonCode) }), ...(reasonReference && reasonReference.length !== 0 && { reasonReference: reasonReference }), ...(transformedTelecom && transformedTelecom.length !== 0 && { telecom: transformedTelecom }), ...(note && note.length !== 0 && { note: note }), ...(participant && participant.length !== 0 && { participant: participant.map((p: any) => { const careTeamParticipant: CareTeamParticipant = { ...(p.role && { role: transformToCodeableConcept(p.role) }), ...(p.member && { member: transformToReferenceCollection(p.member)[0] }), ...(p.onBehalfOf && { onBehalfOf: p.onBehalfOf }) }; return addFhirPeriodIfApplicable(careTeamParticipant, p); }) }) }; return addFhirPeriodIfApplicable(careTeam, item); }, [createReferenceObject, transformToCodeableConcept, addFhirPeriodIfApplicable, getEncounterDisplay, transformToReferenceCollection, transformToTelecom]); const transformFromFhirCareTeam = useCallback((careTeam: CareTeam): any => { const { status, category, name, subject, encounter, participant, reasonCode, reasonReference, note, meta } = careTeam; const formData = { meta: meta, status: status, category: transformFromCodeableConcept(category ?? []), name: name ?? '', subject: subject, encounter: encounter, reasonCode: transformFromCodeableConcept(reasonCode ?? []), reasonReference: reasonReference ?? [], telecom: transformFromTelecom(careTeam), note: note ?? [], participant: (participant ?? []).map((p: CareTeamParticipant) => { const extendedParticipant: any = { role: transformFromCodeableConcept(p.role), member: transformFromReferenceCollection(p.member ? [p.member] : []), onBehalfOf: p.onBehalfOf }; return addPeriodControlsIfApplicable(extendedParticipant, p); }) }; return addPeriodControlsIfApplicable(formData, careTeam); }, [transformFromCodeableConcept, addFhirPeriodIfApplicable, transformFromReferenceCollection, transformFromTelecom]); const getCareTeamDisplay = (careTeam: CareTeam): string => { if (!careTeam) { return 'Unknown Care Team'; } const teamName = careTeam.name || 'Unnamed Care Team'; // - the human label, never the code. `statusLabel` returns `undefined` for a code we // have no words for, and this omits the status entirely in that case rather than falling back to // the raw value - the fallback IS the defect. `utils/fhirStatusVocabulary.ts` const statusText = statusLabel(careTeam.status); const status = statusText ? ` ยท ${statusText}` : ''; let dateSuffix = ''; if (careTeam.period) { dateSuffix = ` (${formatFhirPeriod(careTeam.period)})`; } return `${teamName}${status}${dateSuffix}`; }; return { careTeamsBundle, // WHO THE CACHED LIST IS FOR, so a consumer can ask whether it belongs to the patient on // screen rather than trusting an app-wide context with no patient in its name. cachedForPatientId: cacheOwner.ownerId, ownedScope, careTeamsList, openListWriter, writeCareTeamsList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchCareTeams, fetchCareTeamsWithPolling, fetchCareTeam, fetchCareTeamHistory, createCareTeam, updateCareTeam, deleteCareTeam, archiveCareTeam, transformToFhirCareTeam, transformFromFhirCareTeam, getCareTeamDisplay }; }; const CareTeamServiceContext = createContext | undefined>(undefined); export const CareTeamServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const careTeamService = useCareTeamService(); return ( {children} ); }; export const useCareTeamServiceContext = () => { const context = useContext(CareTeamServiceContext); if (context === undefined) { throw new Error('useCareTeamServiceContext must be used within a CareTeamServiceProvider'); } return context; };