import { useCallback, useMemo, useRef, useState } from 'react'; import { CareTeam } from 'fhir/r4'; import { useFhirCareTeams } from './useFhirCareTeams.tsx'; import { useAuthServiceContext, useCareTeamServiceContext, usePatientServiceContext } from '../services'; import { OutsideProviderEntry, buildOutsideProvidersTeam, extractOutsideProviders, findOutsideProvidersTeam, nextLocalId } from '../utils/outsideProviders.ts'; import { useCareTeamReadScope } from './useCareTeamReadScope'; import { useScopeLifetime } from './useScopeLifetime'; import { RECORD_CHANGED_MESSAGE, RecordChangedError } from '../utils/recordChanged'; /** What an editor was opened on: the team version and scope lifetime its draft belongs to. */ export type OutsideProvidersOpening = { teamId?: string; versionId?: string; generation: number }; const openingOf = (team: CareTeam | undefined, generation: number): OutsideProvidersOpening => ({ teamId: team?.id, versionId: team?.meta?.versionId, generation }); const isOpenedOn = (team: CareTeam | undefined, opening: OutsideProvidersOpening): boolean => team?.id === opening.teamId && team?.meta?.versionId === opening.versionId; export const useFhirOutsideProviders = () => { const { loading, error, careTeamsList } = useFhirCareTeams(); const { patientState } = usePatientServiceContext(); const scope = useCareTeamReadScope(patientState?.id); const { generation, isLive } = useScopeLifetime(scope); const { wpOrganization, currentOrganization } = useAuthServiceContext(); const { createCareTeam, updateCareTeam, deleteCareTeam, fetchCareTeams, fetchCareTeamsWithPolling, openListWriter, writeCareTeamsList, setLastUpserted, setShouldReload } = useCareTeamServiceContext(); // Which save is busy, and in which lifetime: a save's settling clears only its own busy state. const [busy, setBusy] = useState<{ save: number; generation: number } | null>(null); const latestSave = useRef(0); const saving = busy?.generation === generation; const outsideProvidersTeam = useMemo( () => findOutsideProvidersTeam(careTeamsList), [careTeamsList] ); const entries = useMemo( () => extractOutsideProviders(outsideProvidersTeam), [outsideProvidersTeam] ); const applyEntries = useCallback(async ( mutate: (current: OutsideProviderEntry[]) => OutsideProviderEntry[], opening?: OutsideProvidersOpening ): Promise => { // The save belongs to the patient, practice and scope lifetime it was started in, all from the render the // scope came from: once the lifetime ends - even by a return to the same scope - or the held practice // moves away from it, nothing after a wait may write, flag or publish for it. const patient = patientState; const organization = currentOrganization; // An editor's draft belongs to the team version and lifetime it was opened on, never a newer one. const pinned = opening ?? openingOf(outsideProvidersTeam, generation); const live = () => isLive(pinned.generation) && wpOrganization.current?.id === organization?.id; // A save kept from an ended lifetime starts nothing: no busy state, no read, no write. An open editor is told. if (!live()) { if (opening) throw new RecordChangedError(RECORD_CHANGED_MESSAGE); return; } if (!isOpenedOn(outsideProvidersTeam, pinned)) throw new RecordChangedError(RECORD_CHANGED_MESSAGE); if (!patient) { throw new Error('No patient in context'); } // The neutral-host read needs no practice; this write still does, and does not gain one by default. if (!organization) { throw new Error('No practice in context'); } // The fresh read is the baseline the write is built from, so an overtaken answer is refused. const admission = { isLive: live, baseline: true }; const transport = { organization, admission }; const save = ++latestSave.current; setBusy({ save, generation }); try { // Fresh fetch immediately before the PUT so we never clobber // participants added in another session (whole-resource PUT). const bundle = await fetchCareTeamsWithPolling(patient, organization, true, scope, admission); if (!live()) return; const freshTeams = (bundle.entry ?? []) .map(entry => entry.resource) .filter((resource): resource is CareTeam => !!resource); const freshTeam = findOutsideProvidersTeam(freshTeams); // The edit was made on the team as opened. If that has changed since, nothing is merged onto the newer one. if (!isOpenedOn(freshTeam, pinned)) { throw new RecordChangedError(RECORD_CHANGED_MESSAGE); } const baseline = freshTeam; const nextEntries = mutate(extractOutsideProviders(baseline)); // Opened before the write is sent, so a newer read or accepted list change during the wait // supersedes this save's reload rather than the other way round. const writer = openListWriter(scope, patient.id, organization.id, true); const reloadUntil = async (isVisible: (teams: CareTeam[]) => boolean): Promise => { try { let retries = 10; for (;;) { if (!live()) return; const reloaded = await fetchCareTeams(patient, organization, admission); if (!live()) return; const teams = (reloaded.entry ?? []) .map(entry => entry.resource) .filter((resource): resource is CareTeam => !!resource); if (isVisible(teams) || --retries <= 0) { writeCareTeamsList(writer, () => teams); break; } await new Promise(resolve => setTimeout(resolve, 3000)); } } catch (reloadError) { console.error('Outside providers reload after save failed:', reloadError); } }; if (nextEntries.length === 0) { if (!baseline) { return; } await deleteCareTeam(baseline, baseline.meta?.versionId, transport); if (!live()) return; setShouldReload(true); await reloadUntil(teams => !teams.some(team => team.id === baseline.id)); return; } const careTeam = buildOutsideProvidersTeam(patient, organization, nextEntries, baseline); const saved = baseline ? await updateCareTeam(careTeam, baseline.meta?.versionId, transport) : await createCareTeam(careTeam, transport); if (!live()) return; setLastUpserted(saved.id); setShouldReload(true); // fetchCareTeamsWithPolling in this closure still sees the // pre-save lastUpserted (state lands on the next render), so // poll here until the saved team is visible, then push the // fresh list into shared service state. await reloadUntil(teams => teams.some(team => team.id === saved.id)); } finally { setBusy((current) => (current?.save === save ? null : current)); } }, [ patientState, wpOrganization, currentOrganization, outsideProvidersTeam, fetchCareTeams, fetchCareTeamsWithPolling, scope, generation, isLive, openListWriter, writeCareTeamsList, createCareTeam, updateCareTeam, deleteCareTeam, setLastUpserted, setShouldReload ]); const openEditor = useCallback( (): OutsideProvidersOpening => openingOf(outsideProvidersTeam, generation), [outsideProvidersTeam, generation] ); const saveEntry = useCallback(async ( entry: Omit, localId?: string, opening?: OutsideProvidersOpening ): Promise => applyEntries(current => localId ? current.map(e => (e.localId === localId ? { ...entry, localId } : e)) : [...current, { ...entry, localId: nextLocalId(current) }], opening), [applyEntries]); const removeEntry = useCallback(async (localId: string): Promise => applyEntries(current => current.filter(e => e.localId !== localId)), [applyEntries]); return { loading, saving, error, entries, openEditor, saveEntry, removeEntry }; };