import { useState, useCallback, createContext, useContext, ReactNode, useEffect, useRef } from 'react'; import { overlayHeldBundle, writtenSince } from '../utils/writeThroughHold'; import { asRecordChanged, ifMatch, PAGE_OUT_OF_DATE_MESSAGE, RecordChangedError } from '../utils/recordChanged'; import type { SaveTransport } from '../utils/saveOperation'; import { useCacheOwner } from '../utils/cacheOwner'; import { Address, Bundle, ContactPoint, Extension, Location, LocationHoursOfOperation, Organization } from 'fhir/r4'; import { useHttpService } from './HttpService.tsx'; import { environment } from '../environments/environment.ts'; // ONE definition of the timezone extension URL, shared with the resolver that reads it. The // campaign SMS footer proved the cost of two copies of a string that must match: a drift here means // booking silently falls back to the org's clock, which is the failure this whole change removes. import { TIMEZONE_EXTENSION_URL, withTimezoneExtension } from '../utils/practiceTimezone'; import { useFhirServiceContext } from './FhirService.tsx'; import { statusLabel } from '../utils/fhirStatusVocabulary'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; import { searchPageSize } from '../utils/pageSizeOverride'; import { trimmedOrUndefined } from '../utils/requiredText'; import { HttpError } from './HttpService.tsx'; import { officeClaimOf, type FirstOfficeAnswer } from '../utils/receivingOfficeHold'; const API_URL = environment.fhirBaseApi; export const useLocationService = () => { const { transformToCodeableConcept, transformFromCodeableConcept, transformToIdentifier, transformFromIdentifier, transformToAddress, transformFromAddress, createReferenceObject, transformToTelecom, transformFromTelecom, transformToOperationalHours, transformFromOperationalHours, unpackBundle } = useFhirServiceContext(); const { get, post, put, delete: del } = useHttpService(); const [locationsList, setLocationsList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [locationsBundle, setLocationsBundle] = useState | null>(null); const locationsBundleRef = useRef | null>(null); const lastFetchTime = useRef(0); const cacheOwner = useCacheOwner('Location', () => { setLastUpserted(null); setLocationsBundle(null); setLocationsList([]); lastFetchTime.current = 0; }); useEffect(() => { locationsBundleRef.current = locationsBundle; }, [locationsBundle]); const deleteMultiple = useCallback(async (ids: string[]): Promise => { const deleteRequests = ids.map(id => del(`${API_URL}/Location/${id}`)); return Promise.all(deleteRequests); }, [del]); const fetchLocations = useCallback(async (organization: Organization): Promise> => { return 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}/Location?${params.toString()}`, { organizationId: organization.id }); }).then((bundle) => overlayHeldBundle('Location', bundle, (resource) => resource.managingOrganization?.reference === `Organization/${organization.id}`) as Bundle); }, [get]); // `fresh`: the record as stored now, never the cached copy - what a refused save shows as "saved now". const fetchLocation = useCallback(async (id: string, fresh = false, transport?: SaveTransport): Promise => { const cachedLocation = !fresh && cacheOwner.isCurrent(cacheOwner.claimed) ? locationsList.find(location => location.id === id) : undefined; if (cachedLocation) { return cachedLocation; } return get(`${API_URL}/Location/${id}`, { ...transport }); }, [get, locationsList]); const fetchLocationsWithPolling = 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('Location', lastFetchTime.current) && locationsBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(organization.id)) { return locationsBundleRef.current; } const operation = cacheOwner.begin(); let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: Location[]; do { bundle = await fetchLocations(organization); entries = unpackBundle(bundle); found = lastUpserted ? entries.some(e => e.id === lastUpserted) : true; if (!found) { await new Promise(resolve => setTimeout(resolve, delay)); } retries--; } while (!found && retries > 0 && cacheOwner.publishes(operation)); if (!cacheOwner.publishes(operation)) return cacheOwner.answerFor(operation, bundle); setLocationsBundle(bundle); setLocationsList(entries); setLastUpserted(null); setShouldReload(false); lastFetchTime.current = now; cacheOwner.claim(organization.id, operation); return bundle; }, [fetchLocations, unpackBundle, lastUpserted, shouldReload]); // A receiving practice's first office, named by the operation: whatever the retry, tab or viewer, the server answers // the one office at the practice's singleton id. A refusal says why; an unconfirmed answer is never "not made". const createFirstOffice = useCallback(async ( location: Location, options: { operationId: string; makePrimary: boolean; transport?: SaveTransport }, ): Promise => { try { const answer = await post<{ state?: string; operationId?: string; location?: Location }>(`${API_URL}/Location`, location, { headers: { 'X-Operation-Id': options.operationId, 'X-First-Office-Primary': String(options.makePrimary) }, ...options.transport, }); options.transport?.admit(); setShouldReload(true); if (answer?.state !== 'committed' || !answer.location) return { state: 'unconfirmed' }; return { state: 'committed', operationId: answer.operationId ?? options.operationId, location: answer.location, primary: officeClaimOf(answer.location)?.primary ?? null }; } catch (error) { if (!(error instanceof HttpError) || error.status !== 409) throw error; const body = (error.body ?? {}) as { reason?: string; location?: Location }; if (body.reason === 'operation-mismatch' && body.location) return { state: 'mismatch', location: body.location }; return body.reason === 'office-archived' ? { state: 'archived' } : { state: 'held' }; } }, [post]); // Reads the practice's first office back by its singleton id. Not found means not found yet, never "not made". const readFirstOffice = useCallback(async (transport?: SaveTransport): Promise => { try { const answer = await get<{ state?: string; operationId?: string; primary?: unknown; location?: Location }>( `${environment.orgAuthorizationApi}/practice-first-office`, { ...transport }); transport?.admit(); if (answer?.state === 'archived') return { state: 'archived' }; if (answer?.state !== 'committed' || !answer.location || !answer.operationId) return { state: 'unconfirmed' }; return { state: 'committed', operationId: answer.operationId, location: answer.location, primary: officeClaimOf(answer.location)?.primary ?? null }; } catch (error) { if (error instanceof HttpError && error.status === 404) return { state: 'none' }; if (error instanceof HttpError && error.status === 409) return { state: 'held' }; throw error; } }, [get]); // Every reader shares one cached list, so a saved change marks it stale: the next reader asks again // rather than offering the list from before the save. // `transport`: the save this belongs to; its answer marks the list stale only while that save still belongs. const createLocation = useCallback(async (location: Location, transport?: SaveTransport): Promise => { const saved = await post(`${API_URL}/Location`, location, { ...transport }); transport?.admit(); setShouldReload(true); return saved; }, [post]); // CONDITIONAL: written only over `basedOn`, the version of the location the caller read or opened. A refusal // is a RecordChangedError: 412 another write landed first, 428 this page named no version. // `transport`: the save this belongs to; its answer reloads the list only while that save still belongs on screen. const updateLocation = useCallback(async (location: Location, basedOn: string | undefined, transport?: SaveTransport): Promise => { if (!basedOn) throw new RecordChangedError(PAGE_OUT_OF_DATE_MESSAGE, true); const saved = await put(`${API_URL}/Location/${location.id}`, location, { headers: ifMatch(basedOn), ...transport }) .catch((error: unknown) => { throw asRecordChanged(error); }); transport?.admit(); setShouldReload(true); return saved; }, [put]); const deleteLocation = useCallback(async (location: Location, transport?: SaveTransport): Promise => { const removed = await del(`${API_URL}/Location/${location.id}`, { ...transport }); transport?.admit(); setShouldReload(true); return removed; }, [del]); const fetchHistory = useCallback(async (id: string): Promise> => { return get>(`${API_URL}/Location/${id}/_history`); }, [get]); const transformToFhirLocation = useCallback((item: any, organization?: Organization): Location => { const { name, alias, description, mode, type, status, isPartOf, partOf, operationalStatus, physicalType, position, address, telecom, hoursOfOperation, availabilityExceptions, identifier, timezone, extension, meta } = item; // // Extensions: the site's TIMEZONE, and everything else carried through untouched. // // THIS TRANSFORM REBUILDS THE RESOURCE FROM A FIXED FIELD LIST, and a FHIR PUT REPLACES THE // WHOLE RESOURCE - so anything not named here is DELETED on the next edit, silently. Before // this block there was not one mention of `extension` in this service, which is exactly the // trap that stripped org affiliations off `meta.tag` once already. // // THE PASS-THROUGH IS NOT SPECULATIVE GENERALITY - it is what stops the next person who adds // a Location extension from re-living this. Their field survives an edit made by a form that // has never heard of it. // // The timezone is the SITE's clock and is what booking authors a schedule's hours in (Spencer, // 2026-08-10: the timezone belongs on the location). An empty value REMOVES the extension // rather than writing a blank code, so "not set" stays distinguishable from "set to nothing" - // `resolveBookingTimeZone` falls back to the organization on absence, and a blank valueCode // would defeat that. // const nextExtensions: Extension[] = withTimezoneExtension(extension, timezone); const usCoreLocationProfile: string = 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-location'; const transformedAddress: Address[] = transformToAddress(address); const transformedTelecom: ContactPoint[] = transformToTelecom(telecom, 'Organization'); const transformedHoursOfOperation = transformToOperationalHours(hoursOfOperation, 'openingTime', 'closingTime'); const transformedIdentifier = transformToIdentifier(identifier); const location: Location = { resourceType: 'Location', // THE NAME IS TRIMMED, like the aliases below it have been since a real server // refusal. It was the one required text field that was not: the server rejects `""` with a // raw validation dump and STORES `" "`, so a name of only spaces saved successfully and // every screen that lists locations rendered a gap. `undefined` rather than `""` omits the // element, which is the same statement in FHIR's own terms. name: trimmedOrUndefined(name), meta: { ...meta, profile: [usCoreLocationProfile] }, ...(organization && { managingOrganization: createReferenceObject('Organization', organization.id as string, organization.name) }), // ALIASES ARE TRIMMED AND BLANKS DROPPED, and this is the fix for a real server refusal. // // Three forms seed the alias array with a SINGLE SPACE so one input renders. The old guard // asked `a !== ''`, and `' '` passes it - so `alias: [" "]` was written to FHIR: a string // element whose value is whitespace. That is precisely what HealthLake reports as // ERROR: @value cannot be empty // ERROR: ele-1: 'All FHIR elements must have a @value or children' // a dump that names no field, on the one setup step the checklist marks Required. // // AND `every` WAS THE WRONG QUANTIFIER TOO. Two aliases where one is blank failed the test // and dropped BOTH, silently discarding a real one the person had typed. ...(() => { const cleaned = (alias ?? []) .map((a: string) => (a ?? '').trim()) .filter((a: string) => a.length > 0); return cleaned.length > 0 ? { alias: cleaned } : {}; })(), ...(description && { description: description }), ...(status && { status: status }), ...(mode && { mode: mode }), ...(type && type.length !== 0 && { type: transformToCodeableConcept(type) }), ...(isPartOf && partOf && { partOf: partOf }), ...(operationalStatus && { operationalStatus: operationalStatus }), ...(physicalType && { physicalType: transformToCodeableConcept(physicalType) }), ...(position.latitude && position.longitude && { position: { latitude: position.latitude, longitude: position.longitude, ...(position.altitude && { altitude: position.altitude }) } }), ...(transformedAddress.length !== 0 && { address: { ...transformedAddress[0] } }), ...(transformedTelecom.length !== 0 && { telecom: transformedTelecom }), ...(availabilityExceptions && { availabilityExceptions: availabilityExceptions }), ...(transformedHoursOfOperation.length !== 0 && { hoursOfOperation: transformedHoursOfOperation }), ...(transformedIdentifier.length !== 0 && { identifier: transformedIdentifier }), ...(nextExtensions.length !== 0 && { extension: nextExtensions }) }; return location; }, [ transformToAddress, transformToTelecom, transformToOperationalHours, transformToIdentifier, transformToCodeableConcept, createReferenceObject ]); const transformFromFhirLocation = useCallback((location: Location): any => { const { name, alias, description, status, operationalStatus, mode, type, physicalType, position, hoursOfOperation, availabilityExceptions, managingOrganization, identifier, partOf, extension, meta } = location; return { meta: meta, name: name ?? '', // An EMPTY string, never a space. One input still renders, and the outgoing transform // drops it cleanly instead of writing a whitespace-valued element the server refuses. alias: (alias ?? ['']), description: description ?? '', isPartOf: !!partOf, partOf: partOf, status: status ?? '', operationalStatus: operationalStatus ?? null, mode: mode, type: transformFromCodeableConcept(type), physicalType: transformFromCodeableConcept(physicalType), address: transformFromAddress(location), telecom: transformFromTelecom(location), position: { longitude: position?.longitude ?? null, latitude: position?.latitude ?? null, altitude: position?.altitude ?? null }, availabilityExceptions: availabilityExceptions ?? '', hoursOfOperation: transformFromOperationalHours(hoursOfOperation as LocationHoursOfOperation[], 'openingTime', 'closingTime'), identifier: transformFromIdentifier(identifier ?? []), managingOrganization: managingOrganization ?? null, // The site's clock, surfaced as a plain form value. '' means "not set" - which is a real // state, not an error: resolveBookingTimeZone falls back to the organization. timezone: (extension ?? []).find((ext) => ext.url === TIMEZONE_EXTENSION_URL)?.valueCode ?? '', // CARRIED BACK OUT SO transformToFhirLocation CAN PUT IT BACK IN. Without this the // round-trip through the edit form is what deletes every other extension - the form never // sees them, so it cannot return them. extension: extension ?? [], }; }, [ transformFromCodeableConcept, transformFromAddress, transformFromIdentifier, transformFromTelecom, transformFromOperationalHours ]); const getLocationDisplay = (location: Location): string => { if (!location) { return 'Unknown Location'; } const locationName = location.name || 'Unnamed Location'; // - 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(location.status); const status = statusText ? ` ยท ${statusText}` : ''; return `${locationName}${status}`; }; return { locationsBundle, locationsList, setLocationsList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, deleteMultiple, fetchLocations, fetchLocation, fetchLocationsWithPolling, createLocation, createFirstOffice, readFirstOffice, updateLocation, deleteLocation, fetchHistory, transformToFhirLocation, transformFromFhirLocation, getLocationDisplay }; }; const LocationServiceContext = createContext | undefined>(undefined); export const LocationServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const locationService = useLocationService(); return ( {children} ); }; export const useLocationServiceContext = () => { const context = useContext(LocationServiceContext); if (context === undefined) { throw new Error('useLocationServiceContext must be used within a LocationServiceProvider'); } return context; };