import { useState, useCallback, createContext, useContext, ReactNode } from 'react'; import { overlayHeldBundle, refersToPatient, writtenSince } from '../utils/writeThroughHold'; import { Bundle, Media, Patient } from 'fhir/r4'; import { environment } from '../environments/environment.ts'; import { useFhirServiceContext } from './FhirService.tsx'; import { useHttpService } from './HttpService.tsx'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; import { searchPageSize } from '../utils/pageSizeOverride'; import { useCacheOwner } from '../utils/cacheOwner'; // - WAS `environment.healthLakeEndpoint`, A KEY THAT DOES NOT EXIST ON `environment`. // It evaluated to `undefined`, so every URL began with the literal string "undefined", became a RELATIVE // url, resolved against the WordPress origin and 404'd. Media has never loaded for any patient. // DO NOT restore a `healthLakeEndpoint` key to make the old code work: that name meant "talk to // HealthLake directly from the browser", which bypasses the authorizer entirely. The endpoints now exist // behind the gateway ( added /R4/Media to the fhir-clinical stack); point at those. // `fhirClinicalApi` ends in `/R4` and carries NO trailing slash, so the call sites below take a `/` // separator - the old constant was written expecting a trailing slash, and swapping it alone would // produce `.../R4Media`. Same repair already proven in `ImagingStudyService` / `ImagingFrameService`. const API_URL = environment.fhirClinicalApi; const ORGANIZATION_TAG = 'https://akashichealthrecords.com/fhir/tags/organization'; const CREATED_AT = 'https://akashichealthrecords.com/fhir/extensions/createdAtOrganization'; // Offered for editing only when its server stamp proves this practice created it and its version is known, as the // server requires; anything else is read-only here. The server still decides. export const mediaChangeableHere = (media: Media | null | undefined, practiceId: string | null | undefined): boolean => { if (!media?.meta?.versionId || !practiceId) return false; const tags = (media.meta.tag ?? []).filter((tag) => tag?.system === ORGANIZATION_TAG); const stamps = (media.extension ?? []).filter((extension) => typeof extension?.url === 'string' && extension.url.endsWith('/createdAtOrganization')); return tags.length === 1 && stamps.length === 1 && stamps[0].url === CREATED_AT && tags[0].code === practiceId && stamps[0].valueReference?.reference === `Organization/${practiceId}`; }; const MEDIA_REFUSALS: Record = { MEDIA_OTHER_PRACTICE: 'Only the practice that added this media can change or remove it.', MEDIA_ORIGIN_UNCONFIRMED: "This media's practice cannot be confirmed, so it cannot be changed or removed here.", MEDIA_EDITION_STALE: 'This media changed since you opened it. Nothing was saved. Reload it before editing again.', MEDIA_EDITION_REQUIRED: 'This media changed since you opened it. Nothing was saved. Reload it before editing again.', MEDIA_EDITION_MISSING: "This media's stored version could not be read, so nothing was changed. Reload it and try again.", MEDIA_CHANGED: 'This media changed before it could be saved or removed. Nothing was changed. Reload it and try again.', }; /** The server's own words for a refused Media change it names by code, or undefined. */ export const mediaRefusalCopy = (error: unknown): string | undefined => { const body = (error as { body?: unknown } | null)?.body; const code = typeof body === 'object' && body !== null ? (body as { code?: unknown }).code : undefined; return typeof code === 'string' ? MEDIA_REFUSALS[code] : undefined; }; export const useMediaService = () => { const { get, post, put, delete: del } = useHttpService(); const { transformToCodeableConcept, transformFromCodeableConcept, createReferenceObject, formatDate, extractReferenceId } = useFhirServiceContext(); const [mediaState, setMediaState] = useState(null); const [mediaBundle, setMediaBundle] = useState | null>(null); const [mediaList, setMediaList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [lastFetchTime, setLastFetchTime] = useState(0); const cacheOwner = useCacheOwner('Media', () => { setLastUpserted(null); setMediaBundle(null); setMediaList([]); setMediaState(null); setLastFetchTime(0); }); const fetchMediaList = useCallback(async (patient: Patient): Promise> => { return fetchAllPagesAsBundle((pageParam) => { const params = new URLSearchParams(); params.append('subject', patient.id as string); // No `_count` was stated here either - see ImagingStudyService for the same note. params.append('_count', searchPageSize('100')); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/Media?${params.toString()}`); }).then((bundle) => overlayHeldBundle('Media', bundle, (resource) => refersToPatient(resource, patient.id)) as Bundle); }, [get]); const fetchMediaListWithPolling = useCallback(async (patient: Patient, forceReload = false): Promise> => { const now = Date.now(); const cacheAge = now - lastFetchTime; const cacheFresh = cacheAge < 5 * 60 * 1000; // A fresh bundle is a hit only when it is this patient's: the cache outlives a change of patient. if (!forceReload && !shouldReload && !writtenSince('Media', lastFetchTime) && mediaBundle && cacheFresh && cacheOwner.isCurrentFor(patient.id)) { return mediaBundle; } const operation = cacheOwner.begin(); let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; do { bundle = await fetchMediaList(patient); const mediaItems = (bundle.entry ?? []).map(entry => entry.resource) as Media[]; found = lastUpserted ? mediaItems.some(q => q.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); setMediaBundle(bundle); setMediaList((bundle.entry ?? []).map(entry => entry.resource) as Media[]); cacheOwner.claim(patient.id, operation); setLastFetchTime(now); setLastUpserted(null); setShouldReload(false); return bundle; }, [fetchMediaList, lastUpserted, shouldReload, mediaBundle, lastFetchTime, cacheOwner]); const fetchMedia = useCallback(async (id: string): Promise => { const cachedMedia = cacheOwner.isCurrent(cacheOwner.claimed) ? mediaList.find(media => media.id === id) : undefined; if (cachedMedia) { return cachedMedia; } return get(`${API_URL}/Media/${id}`); }, [get, mediaList]); const fetchMediaHistory = useCallback(async (id: string): Promise> => { return get>(`${API_URL}/Media/${id}/_history`); }, [get]); const createMedia = useCallback(async (media: Media): Promise => { // - the subject goes in the QUERY STRING, not only in the body. `POST /R4/Media` // sits behind `requirePatientAccess`, which runs in an API Gateway request authorizer, and an // authorizer never receives a body. Without this the create is refused before any handler runs, // and the browser is told only that the ACCOUNT lacks access. Same key this service already // searches with, so the two calls agree. const params = new URLSearchParams(); params.append('subject', extractReferenceId(media.subject) as string); return post(`${API_URL}/Media?${params.toString()}`, media); }, [post, extractReferenceId]); const updateMedia = useCallback(async (media: Media): Promise => { return put(`${API_URL}/Media/${media.id}`, media); }, [put]); const deleteMedia = useCallback(async (media: Media): Promise => { return del(`${API_URL}/Media/${media.id}`); }, [del]); const transformToFhirMedia = useCallback((item: any, patient: Patient): Media => { const { identifier, status, type, modality, view, subject, encounter, createdDateTime, issued, operator, reasonCode, bodySite, deviceName, device, height, width, frames, duration, content, note, basedOn, partOf } = item; let media: Media = { resourceType: 'Media', status: status, content: content, subject: createReferenceObject('Patient', patient.id as string), ...(identifier && identifier.length > 0 && { identifier }), ...(type && { type: transformToCodeableConcept(type) }), ...(modality && { modality: transformToCodeableConcept(modality) }), ...(view && { view: transformToCodeableConcept(view) }), ...(encounter && { encounter: createReferenceObject('Encounter', encounter.id) }), ...(createdDateTime && { createdDateTime: formatDate(createdDateTime) }), ...(issued && { issued: formatDate(issued) }), ...(operator && { operator: createReferenceObject(operator.resourceType, operator.id) }), ...(reasonCode && reasonCode.length > 0 && { reasonCode: reasonCode.map((reason: any) => transformToCodeableConcept(reason)) }), ...(bodySite && { bodySite: transformToCodeableConcept(bodySite) }), ...(deviceName && { deviceName }), ...(device && { device: createReferenceObject('Device', device.id) }), ...(height !== undefined && { height }), ...(width !== undefined && { width }), ...(frames !== undefined && { frames }), ...(duration !== undefined && { duration }), ...(note && note.length > 0 && { note }), ...(basedOn && basedOn.length > 0 && { basedOn: basedOn.map((base: any) => createReferenceObject(base.resourceType, base.id)) }), ...(partOf && partOf.length > 0 && { partOf: partOf.map((part: any) => createReferenceObject(part.resourceType, part.id)) }) }; return media; }, [createReferenceObject, transformToCodeableConcept, formatDate]); const transformFromFhirMedia = useCallback((media: Media): any => { const { identifier, status, type, modality, view, encounter, createdDateTime, issued, operator, reasonCode, bodySite, deviceName, device, height, width, frames, duration, content, note, basedOn, partOf } = media; const formData = { identifier: identifier || [], status: status || 'preparation', type: transformFromCodeableConcept(type), modality: transformFromCodeableConcept(modality), view: transformFromCodeableConcept(view), encounter: encounter || null, createdDateTime: createdDateTime || '', issued: issued || '', operator: operator || null, reasonCode: reasonCode ? reasonCode.map(reason => transformFromCodeableConcept(reason)) : [], bodySite: transformFromCodeableConcept(bodySite), deviceName: deviceName || '', device: device || null, height: height || 0, width: width || 0, frames: frames || 0, duration: duration || 0, content: content || { contentType: '', data: '', url: '', size: 0 }, note: note || [], basedOn: basedOn || [], partOf: partOf || [] }; return formData; }, [transformFromCodeableConcept]); return { mediaState, setMediaState, mediaBundle, mediaList, cachedForPatientId: cacheOwner.ownerId, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchMediaList, fetchMediaListWithPolling, fetchMedia, fetchMediaHistory, createMedia, updateMedia, deleteMedia, transformToFhirMedia, transformFromFhirMedia }; }; const MediaServiceContext = createContext | undefined>(undefined); export const MediaServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const mediaService = useMediaService(); return ( {children} ); }; export const useMediaServiceContext = () => { const context = useContext(MediaServiceContext); if (context === undefined) { throw new Error('useMediaServiceContext must be used within a MediaServiceProvider'); } return context; };