import { createContext, useContext, ReactNode, useState, useCallback, useEffect, useRef } from 'react'; import { holdDelete, holdWrite, overlayHeldBundle, startWrite, writtenSince } from '../utils/writeThroughHold'; import { beginDeleteAttempt, beginRead, endDeletedAttempt, endUnsentAttempt, evidenceFor, evidenceScope, settleAbsentFromExact, } from '../utils/booking/serviceCatalogueEvidence'; import { useCacheOwner } from '../utils/cacheOwner'; import { Attachment, Bundle, ContactPoint, HealthcareService, HealthcareServiceAvailableTime, HealthcareServiceNotAvailable, Organization } from 'fhir/r4'; import { environment } from '../environments/environment.ts'; import { useFhirServiceContext } from './FhirService.tsx'; import { useHttpService } from './HttpService.tsx'; import { buildHealthcareServiceSearchParams } from './healthcareServiceSearch'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; import { searchPageSize } from '../utils/pageSizeOverride'; import type { TimeUnit } from '../config/serviceTimeRate'; const API_URL = environment.fhirBaseApi; /** Billing's price-only save; the route lives on the organization-authorization gateway. */ export type ServicePricingInput = | { mode: 'none' } | { mode: 'flat'; cents: number } | { mode: 'timed'; rateCents: number; unit: TimeUnit }; export const useHealthcareServiceService = () => { const { transformToCodeableConcept, transformFromCodeableConcept, transformToIdentifier, transformFromIdentifier, transformToTelecom, transformFromTelecom, transformToOperationalHours, transformFromOperationalHours, transformToOperationalHoursNotAvailable, transformFromOperationalHoursNotAvailable, createReferenceObject, unpackBundle } = useFhirServiceContext(); const { get, post, put, delete: del } = useHttpService(); const [healthcareServicesList, setHealthcareServicesList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [healthcareServicesBundle, setHealthcareServicesBundle] = useState | null>(null); const healthcareServicesBundleRef = useRef | null>(null); const lastFetchTime = useRef(0); // The practice this owner last read for, and this owner's own opening, which an exact read must still be in. const lastPractice = useRef(undefined); const opening = useRef({}); const cacheOwner = useCacheOwner('HealthcareService', () => { setLastUpserted(null); setHealthcareServicesBundle(null); setHealthcareServicesList([]); lastFetchTime.current = 0; }); useEffect(() => { healthcareServicesBundleRef.current = healthcareServicesBundle; }, [healthcareServicesBundle]); // The practice is also the request's organization header: the server scopes this read to the header, not the query. const fetchHealthcareServices = useCallback(async (organization: Organization, scopeToOrganization = false): Promise> => { if (!scopeToOrganization) lastPractice.current = organization.id; const bundle = await fetchAllPagesAsBundle((pageParam) => { let params = new URLSearchParams(); params.append('_count', searchPageSize('100')); params.append('organization', organization.id as string); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/HealthcareService?${params.toString()}`, scopeToOrganization ? { organizationId: organization.id } : {}); }); // The list is one practice: a held service belongs when that practice provides it. return overlayHeldBundle('HealthcareService', bundle, (service) => !service.providedBy || service.providedBy.reference === `Organization/${organization.id}`) as Bundle; }, [get]); const fetchHealthcareService = useCallback(async (id: string): Promise => { const cachedService = cacheOwner.isCurrent(cacheOwner.claimed) ? healthcareServicesList.find(service => service.id === id) : undefined; if (cachedService) { return cachedService; } return get(`${API_URL}/HealthcareService/${id}`); }, [get, healthcareServicesList]); const fetchHealthcareServicesWithPolling = useCallback(async (organization: Organization, forceReload = false): Promise> => { const now = Date.now(); const cacheAge = now - lastFetchTime.current; const cacheFresh = cacheAge < 5 * 60 * 1000; if (!forceReload && !shouldReload && !writtenSince('HealthcareService', lastFetchTime.current) && healthcareServicesBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(organization.id)) { return healthcareServicesBundleRef.current; } const operation = cacheOwner.begin(); /* * WHAT USED TO BE HERE AND WHY IT COULD NOT WORK - see writeThroughHold. A retry loop * polled this search up to ten times at three seconds, waiting for the id just written to turn * up, and served the list without it when the thirty seconds ran out. * * A LONGER LOOP IS NOT THE FIX: the lag is not a constant to tune against. Measured on * 2026-08-31, a RelatedPerson was findable inside 14.4 seconds and a Task was still missing at * 2m47s. Holding what the write returned is right at both ends. * * THIS LIST IS THE ONE BOOKING DEPENDS ON, per the note on `searchHealthcareServices` below, so * a service added to the practice and then absent from it is a service nobody can be booked * into - not just a list that looks short. */ const published = await fetchHealthcareServices(organization); if (!cacheOwner.publishes(operation)) return cacheOwner.answerFor(operation, published); setHealthcareServicesBundle(published); setHealthcareServicesList(unpackBundle(published)); lastFetchTime.current = now; cacheOwner.claim(organization.id, operation); setLastUpserted(null); setShouldReload(false); return published; // `lastUpserted` is no longer read here - the hold replaced the wait for it - so it is not a // dependency. It is still SET by a write, because it is what the write has to say. }, [fetchHealthcareServices, unpackBundle, shouldReload]); // Server-side, Paginator-shaped type-to-search (name + pagination) for the Services & rates catalog // and the invoice editor's Service picker. Deliberately separate from fetchHealthcareServicesWithPolling's // shared cached list (which booking/scheduling still rely on) - this never touches that cache. Org // scope is forced server-side (HealthcareService/get.ts), so no organization param is sent. const searchHealthcareServices = useCallback( async (pageSize: number, pageParam?: string, query?: string): Promise> => { const params = buildHealthcareServiceSearchParams({ pageSize, pageParam, query }); // SEARCH-BOUNDED: single-page ON PURPOSE - this backs a UI Paginator, so the CALLER owns the // cursor. Same shape as `fetchInvoices` and `fetchChargeItemDefinitions`. The list that // booking depends on is the SEPARATE cached `fetchHealthcareServices` above, and that one // does page - which is the pairing to keep in mind before "fixing" this one. return get>(`${API_URL}/HealthcareService?${params.toString()}`); }, [get], ); const createHealthcareService = useCallback(async (healthcareService: HealthcareService): Promise => { const result = await post(`${API_URL}/HealthcareService`, healthcareService); setLastUpserted(result.id ?? null); setShouldReload(true); return result; }, [post]); const updateHealthcareService = useCallback(async (healthcareService: HealthcareService): Promise => { const startedIn = startWrite(); const result = await put(`${API_URL}/HealthcareService/${healthcareService.id}`, healthcareService); holdWrite(result ?? healthcareService, Date.now(), startedIn); setLastUpserted(result.id ?? healthcareService.id ?? null); setShouldReload(true); return result; }, [put]); const saveServicePrice = useCallback(async (serviceId: string, pricing: ServicePricingInput): Promise => { const startedIn = startWrite(); const { healthcareService } = await put<{ healthcareService: HealthcareService }>( `${environment.orgAuthorizationApi}/healthcare-service-price/${serviceId}`, pricing, ); // This endpoint answers with the service inside a wrapper, so it is held here. holdWrite(healthcareService, Date.now(), startedIn); setLastUpserted(healthcareService.id ?? serviceId); setShouldReload(true); return healthcareService; }, [put]); // Each attempt is written down before it may leave, and ends only on its own answer: success or absence, or a // refusal before it was sent. Any other failure leaves it owed, since the server deletes before it can still fail. const deleteHealthcareService = useCallback(async (healthcareService: HealthcareService): Promise => { const id = healthcareService.id as string; const practice = healthcareService.providedBy?.reference?.split('/')[1] ?? lastPractice.current ?? ''; const scope = evidenceScope(practice, API_URL); const attempt = beginDeleteAttempt(scope, id); let sent = false; const gone = () => { endDeletedAttempt(scope, id, attempt); holdDelete({ resourceType: 'HealthcareService', id }); setShouldReload(true); }; try { const result = await del(`${API_URL}/HealthcareService/${id}`, { onSend: () => { sent = true; } }); gone(); return result; } catch (error) { const status = (error as { status?: unknown } | null)?.status; if (status === 404 || status === 410) gone(); else if (!sent) endUnsentAttempt(scope, id, attempt); throw error; } }, [del]); // Check: an exact read. Absence is known deletion now, settled only within the read's own life and revision; a // present answer settles nothing, and no attempt is ever ended here. const checkHealthcareService = useCallback(async (id: string, practiceId?: string): Promise<'present' | 'absent'> => { const practice = practiceId ?? healthcareServicesList.find((each) => each.id === id)?.providedBy?.reference?.split('/')[1] ?? lastPractice.current ?? ''; const read = beginRead(evidenceScope(practice, API_URL), opening.current); try { await get(`${API_URL}/HealthcareService/${id}`); return 'present'; } catch (error) { const status = (error as { status?: unknown } | null)?.status; if (status !== 404 && status !== 410) throw error; settleAbsentFromExact(read, opening.current, id); return 'absent'; } }, [get, healthcareServicesList]); // Whether a delete sent from this tab is still owed an answer; evidence that cannot be read counts as owed. const isDeleteUnconfirmed = useCallback((service: HealthcareService): boolean => { const practice = service.providedBy?.reference?.split('/')[1] ?? lastPractice.current ?? ''; try { return (evidenceFor(evidenceScope(practice, API_URL)).services[service.id as string]?.attempts.length ?? 0) > 0; } catch { return true; } }, []); const transformToFhirHealthcareService = useCallback((item: any, photo: Attachment | undefined, organization?: Organization): HealthcareService => { const { name, active, comment, extraDetails, appointmentRequired, category, type, specialty, location, coverageArea, serviceProvisionCode, program, characteristic, communication, referralMethod, availabilityExceptions, availableTime, notAvailable, identifier, telecom, eligibility } = item; const transformedAvailableTime = transformToOperationalHours(availableTime); const transformedNotAvailable = transformToOperationalHoursNotAvailable(notAvailable); const transformedIdentifier = transformToIdentifier(identifier); const transformedTelecom: ContactPoint[] = transformToTelecom(telecom); return { resourceType: 'HealthcareService', name: name, active: !!active, appointmentRequired: !!appointmentRequired, ...(photo && { photo: photo }), ...(organization && { providedBy: createReferenceObject('Organization', organization.id as string, organization.name) }), ...(comment && { comment: comment }), ...(extraDetails && { extraDetails: extraDetails }), ...(category.length !== 0 && { category: transformToCodeableConcept(category) }), ...(type.length !== 0 && { type: transformToCodeableConcept(type) }), ...(specialty.length !== 0 && { specialty: transformToCodeableConcept(specialty) }), ...(serviceProvisionCode.length !== 0 && { serviceProvisionCode: transformToCodeableConcept(serviceProvisionCode) }), ...(program.length !== 0 && { program: transformToCodeableConcept(program) }), ...(characteristic.length !== 0 && { characteristic: transformToCodeableConcept(characteristic) }), ...(communication.length !== 0 && { communication: transformToCodeableConcept(communication) }), ...(referralMethod.length !== 0 && { referralMethod: transformToCodeableConcept(referralMethod) }), ...(availabilityExceptions && { availabilityExceptions: availabilityExceptions }), ...(transformedAvailableTime.length !== 0 && { availableTime: transformedAvailableTime }), ...(transformedNotAvailable.length !== 0 && { notAvailable: transformedNotAvailable }), ...(transformedIdentifier.length !== 0 && { identifier: transformedIdentifier }), ...(transformedTelecom.length !== 0 && { telecom: transformedTelecom }), ...(availabilityExceptions && { availabilityExceptions }), ...(eligibility && eligibility.length > 0 && { eligibility: eligibility.map((e: any) => ({ code: e.code ? transformToCodeableConcept(e.code) : undefined, comment: e.comment })) }), ...(location && location.length !== 0 && { location: location }), ...(coverageArea && coverageArea.length !== 0 && { coverageArea: coverageArea }) }; }, [ transformToCodeableConcept, transformToIdentifier, transformToTelecom, transformToOperationalHours, transformToOperationalHoursNotAvailable, createReferenceObject ]); const transformFromFhirHealthcareService = useCallback((healthcareService: HealthcareService): { photo: Attachment | undefined, formData: any } => { const { name, active, comment, extraDetails, category, type, specialty, serviceProvisionCode, program, characteristic, communication, referralMethod, appointmentRequired, availabilityExceptions, availableTime, notAvailable, identifier, location, coverageArea, photo, providedBy, eligibility } = healthcareService; return { photo: photo, formData: { name: name ?? '', active: active ?? true, providedBy: providedBy ?? null, comment: comment ?? '', extraDetails: extraDetails ?? '', category: transformFromCodeableConcept(category ?? []), type: transformFromCodeableConcept(type ?? []), specialty: transformFromCodeableConcept(specialty ?? []), serviceProvisionCode: transformFromCodeableConcept(serviceProvisionCode ?? []), program: transformFromCodeableConcept(program ?? []), characteristic: transformFromCodeableConcept(characteristic ?? []), communication: transformFromCodeableConcept(communication ?? []), referralMethod: transformFromCodeableConcept(referralMethod ?? []), appointmentRequired: appointmentRequired ?? true, availabilityExceptions: availabilityExceptions ?? '', availableTime: transformFromOperationalHours(availableTime as HealthcareServiceAvailableTime[]), notAvailable: transformFromOperationalHoursNotAvailable(notAvailable as HealthcareServiceNotAvailable[]), telecom: transformFromTelecom(healthcareService), identifier: transformFromIdentifier(identifier), ...(eligibility && eligibility.length !== 0 && { eligibility: (eligibility || []).map(e => ({ code: transformFromCodeableConcept(e.code ? [e.code] : []), comment: e.comment || '' })) }), location: location, coverageArea: coverageArea, } }; }, [ transformFromCodeableConcept, transformFromTelecom, transformFromIdentifier, transformFromOperationalHours, transformFromOperationalHoursNotAvailable ]); const getHealthcareServiceDisplay = (healthcareService: HealthcareService): string => { if (!healthcareService) { return 'Unknown Healthcare Service'; } const serviceName = healthcareService.name || 'Unnamed Service'; const status = healthcareService.active !== false ? ' [Active]' : ' [Inactive]'; return `${serviceName}${status}`; }; return { healthcareServicesBundle, healthcareServicesList, setHealthcareServicesList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchHealthcareServices, fetchHealthcareService, fetchHealthcareServicesWithPolling, searchHealthcareServices, createHealthcareService, updateHealthcareService, saveServicePrice, deleteHealthcareService, checkHealthcareService, isDeleteUnconfirmed, transformToFhirHealthcareService, transformFromFhirHealthcareService, getHealthcareServiceDisplay }; }; const HealthcareServiceServiceContext = createContext | undefined>(undefined); export const HealthcareServiceServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const healthcareServiceService = useHealthcareServiceService(); return ( {children} ); }; export const useHealthcareServiceServiceContext = () => { const context = useContext(HealthcareServiceServiceContext); if (context === undefined) { throw new Error('useHealthcareServiceServiceContext must be used within a HealthcareServiceServiceProvider'); } return context; };