import { useCallback, useEffect, useRef, useState } from "react"; import type { Appointment, Bundle, HealthcareService, Location, Organization, Patient, Schedule } from "fhir/r4"; import { useAppointmentServiceContext, useHealthcareServiceServiceContext, useLocationServiceContext, usePractitionerServiceContext, useScheduleServiceContext, type PractitionerWithRoles, } from "../services"; import { environment } from "../environments/environment.ts"; import { viewerEpoch } from "../utils/writeThroughHold"; import { isWholeCatalogue, serviceCatalogueBasis, type ServiceCatalogueBasis } from "../utils/booking/serviceCatalogueBasis"; import { beginRead, evidenceFor, evidenceScope, onEvidenceChange, settleFromSearch, type EvidenceRead, type ScopeEvidence, } from "../utils/booking/serviceCatalogueEvidence"; export interface AwayBookingCatalog { schedules: Schedule[]; practitioners: PractitionerWithRoles[]; locations: Location[]; healthcareServices: HealthcareService[]; /** The away practice's own catalogue basis: a refused or partial read is never an empty catalogue. */ servicesBasis: ServiceCatalogueBasis; appointments: Appointment[]; loading: boolean; refreshAppointments: () => Promise; /** Reads only the services again; appointments are left as they are. */ retryServices: () => Promise; } type ServicesRead = { bundle?: Bundle; failed: boolean }; const CHECKING: ServicesRead = { failed: false }; const NONE: never[] = []; const resourcesOf = (bundle: Bundle | undefined, type: string): T[] => (bundle?.entry ?? []).flatMap((entry) => { const resource = entry.resource as (T & { resourceType?: string }) | undefined; return resource && (!resource.resourceType || resource.resourceType === type) ? [resource] : []; }); // Booking at a practice other than the portal's own reads that practice's catalog under its own // organization, into this hook's state and never the portal's shared lists. For the portal's own // practice it returns undefined and the shared lists are used as before. export const useAwayBookingCatalog = (organization: Organization | undefined, patient: Patient | undefined): AwayBookingCatalog | undefined => { const { fetchSchedules } = useScheduleServiceContext(); const { fetchPractitioners } = usePractitionerServiceContext(); const { fetchLocations } = useLocationServiceContext(); const { fetchHealthcareServices } = useHealthcareServiceServiceContext(); const { fetchAppointments } = useAppointmentServiceContext(); const organizationId = organization?.id; const patientId = patient?.id; const [catalog, setCatalog] = useState & { organizationId?: string; services: ServicesRead }>(); // Each opening (practice and patient) and each services read in it is its own identity: a retired one, even for // the same practice reopened, publishes and settles nothing. const opening = useRef({}); const attempt = useRef({}); const [, setTick] = useState(0); useEffect(() => onEvidenceChange(() => setTick((n) => n + 1)), []); const refreshAppointments = useCallback(async (): Promise => { if (!organizationId || !patient) return []; const appointments = resourcesOf(await fetchAppointments(patient, undefined, undefined, organizationId), "Appointment"); setCatalog((current) => (current?.organizationId === organizationId ? { ...current, appointments } : current)); return appointments; }, [organizationId, patient, fetchAppointments]); // One services read: stamped before the request, and a whole answer settles this practice's evidence it agrees with. const readServices = useCallback(async (practice: Organization, here: object, mine: object): Promise => { let started: EvidenceRead | undefined; try { started = beginRead(evidenceScope(practice.id ?? "", environment.fhirBaseApi), here); } catch { started = undefined; } let read: ServicesRead; try { read = { bundle: await fetchHealthcareServices(practice, true), failed: false }; } catch (err) { console.error(`Error loading booking data for practice ${practice.id}:`, err); read = { failed: true }; } if (opening.current !== here || attempt.current !== mine) return undefined; if (started && read.bundle && isWholeCatalogue(read.bundle)) { settleFromSearch(started, here, resourcesOf(read.bundle, "HealthcareService")); } return read; }, [fetchHealthcareServices]); useEffect(() => { if (!organization || !organizationId) { setCatalog(undefined); return; } const here = {}; const mine = {}; opening.current = here; attempt.current = mine; const viewer = viewerEpoch(); setCatalog({ organizationId, schedules: NONE, practitioners: NONE, locations: NONE, healthcareServices: NONE, appointments: NONE, loading: true, services: CHECKING }); const settle = (read: Promise, fallback: T) => read.catch((err) => { console.error(`Error loading booking data for practice ${organizationId}:`, err); return fallback; }); void Promise.all([ settle(fetchSchedules(undefined, organizationId), undefined), settle(fetchPractitioners(organizationId), undefined), settle(fetchLocations(organization), undefined), readServices(organization, here, mine), patient ? settle(fetchAppointments(patient, undefined, undefined, organizationId), undefined) : Promise.resolve(undefined), ]).then(([schedules, practitioners, locations, services, appointments]) => { // Ownership is decided when the state is committed: a Retry that took over meanwhile keeps its services, // answered or still pending, and this load brings only the rest. setCatalog((current) => { if (opening.current !== here || viewerEpoch() !== viewer) return current; const ownsServices = Boolean(services) && attempt.current === mine; const previous = current?.organizationId === organizationId ? current : undefined; const kept = ownsServices ? services! : previous?.services ?? CHECKING; return { organizationId, schedules: resourcesOf(schedules, "Schedule"), practitioners: resourcesOf(practitioners as Bundle | undefined, ""), locations: resourcesOf(locations, "Location"), healthcareServices: ownsServices ? resourcesOf(kept.bundle, "HealthcareService") : previous?.healthcareServices ?? NONE, services: kept, appointments: resourcesOf(appointments, "Appointment"), loading: false, }; }); }); return () => { if (opening.current === here) opening.current = {}; }; // The practice and the patient, by id, are what change these reads. // eslint-disable-next-line react-hooks/exhaustive-deps }, [organizationId, patientId, fetchSchedules, fetchPractitioners, fetchLocations, readServices, fetchAppointments]); const retryServices = useCallback(async () => { if (!organization || !organizationId) return; const here = opening.current; const mine = {}; attempt.current = mine; const viewer = viewerEpoch(); const read = await readServices(organization, here, mine); if (!read) return; setCatalog((current) => (current?.organizationId === organizationId && opening.current === here && attempt.current === mine && viewerEpoch() === viewer ? { ...current, services: read, healthcareServices: resourcesOf(read.bundle, "HealthcareService") } : current)); }, [organization, organizationId, readServices]); if (!organizationId || catalog?.organizationId !== organizationId) { return organizationId ? { schedules: NONE, practitioners: NONE, locations: NONE, healthcareServices: NONE, appointments: NONE, loading: true, servicesBasis: { state: "checking", services: [], unconfirmed: [] }, refreshAppointments, retryServices, } : undefined; } let evidence: ScopeEvidence | "unavailable"; try { evidence = evidenceFor(evidenceScope(organizationId, environment.fhirBaseApi)); } catch { evidence = "unavailable"; } const { services, ...rest } = catalog; const servicesBasis = serviceCatalogueBasis({ practiceId: organizationId, services: rest.healthcareServices, bundle: services.bundle, failed: services.failed, evidence, }); return { ...rest, healthcareServices: servicesBasis.services, servicesBasis, refreshAppointments, retryServices }; };