import type { ReadOptions } from '../hooks/anchorView/readOptions'; import { isAsOfIncomplete } from '../utils/searchTruncation'; import { useState, useCallback, createContext, useContext, ReactNode, useEffect, useMemo, useRef } from 'react'; import { Bundle, DocumentReference, DocumentReferenceContent, DocumentReferenceRelatesTo, DocumentReferenceContext, Patient, CodeableConcept, Attachment, Organization } from 'fhir/r4'; import { environment } from '../environments/environment.ts'; import type { SaveTransport } from '../utils/saveOperation'; import { filingApi, underFiling } from './DocumentFilingService'; import { usePhotoUploadService } from './PhotoUploadService'; import { useFhirServiceContext } from './FhirService.tsx'; import { useHttpService } from './HttpService.tsx'; import { formatFhirHumanName } from '../utils/fhirHumanName.tsx'; import { usePatientServiceContext } from './PatientService.tsx'; import { useAuthServiceContext } from './AuthService.tsx'; import { formatFhirCodeableConcept } from '../utils/fhirCodeableConcept.tsx'; import { formatDate } from '../utils/dateUtils.ts'; import { applyArchive, isArchived, type ArchiveReason } from '../utils/archivedResource'; import { statusLabel } from '../utils/fhirStatusVocabulary'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; import { searchPageSize } from '../utils/pageSizeOverride'; import { belongsToList, type DocumentListScope } from '../utils/documentListScope'; import { holdDelete, holdGeneration, isWithheldReceipt, onViewerChange, overlayHeld, overlayHeldBundle, sameViewerSince, searchIsComplete, searchStartedOf, startWrite, viewerEpoch, writtenSince } from '../utils/writeThroughHold'; const API_URL = environment.fhirFoundationApi; export const useDocumentReferenceService = () => { const { get, post, put, delete: del } = useHttpService(); const { uploadSigned } = usePhotoUploadService(); // The staff document filings this practice member prepares, uploads under and recovers (DocumentFilingService.tsx). const filings = useMemo(() => filingApi(get, post, uploadSigned), [get, post, uploadSigned]); const { userRole } = useAuthServiceContext(); const { patientState } = usePatientServiceContext(); const { transformToCodeableConcept, transformFromCodeableConcept, createReferenceObject, formatDateTime, formatDateTimeForInput, addFhirPeriodIfApplicable, addPeriodControlsIfApplicable, unpackBundle, transformToReferenceCollection, transformFromReferenceCollection, transformToIdentifier, transformFromIdentifier, extractReferenceId } = useFhirServiceContext(); const [documentReferencesList, setDocumentReferencesList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [documentReferencesBundle, setDocumentReferencesBundle] = useState | null>(null); // Whose list the bundle is (`org:` or `org::patient:`), so a reader can tell it is its own. const [documentReferencesScope, setDocumentReferencesScope] = useState(null); const documentReferencesBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); // What the cached bundle was loaded for: the practice's own drawer or one patient's documents. const bundleScopeRef = useRef(null); const listScopeRef = useRef(null); // Only the latest request may publish: a slower one for a scope the reader has left is dropped. const latestRequestRef = useRef(0); const generationRef = useRef(-1); // Who the shown list was published for, which publication it is and the hold generation it saw, kept with it: a // read by id answers from it only while it is the newest publication, for this viewer, with nothing held changed. const [publication, setPublication] = useState({ viewer: viewerEpoch(), number: 0, generation: -1 }); const publicationRef = useRef(0); const publish = useCallback((generation: number) => { setPublication({ viewer: viewerEpoch(), number: ++publicationRef.current, generation }); }, []); // A document this viewer just created is readable by id at once on its own boundary: valid for this viewer only // while nothing else held of its type changes, and never lending that validity to the rest of the list. const savedRef = useRef(new Map()); useEffect(() => { documentReferencesBundleRef.current = documentReferencesBundle; }, [documentReferencesBundle]); // Nothing in flight may publish once this is gone. useEffect(() => () => { latestRequestRef.current++; }, []); // A reset leaves no request current, so a response already on its way cannot refill the list. const resetDocuments = useCallback(() => { latestRequestRef.current++; publicationRef.current++; documentReferencesBundleRef.current = null; savedRef.current.clear(); setDocumentReferencesBundle(null); setDocumentReferencesList([]); lastFetchTimeRef.current = 0; bundleScopeRef.current = null; setDocumentReferencesScope(null); }, []); useEffect(() => { if (!patientState || userRole) resetDocuments(); }, [patientState, userRole, resetDocuments]); // Another viewer, acting-as subject or practice sees nothing loaded for the one before. useEffect(() => onViewerChange(resetDocuments), [resetDocuments]); // The documents as the search index has them, with nothing held laid over them. const searchDocumentReferencesAsIndexed = useCallback(async (patient: Patient | undefined, organization: Organization | null | undefined, isRoot: boolean = false, options?: ReadOptions): Promise> => { return fetchAllPagesAsBundle((pageParam) => { const params = new URLSearchParams(); params.append('_count', searchPageSize('100')); // The patient host has no practice: the patient asks by subject, and the server answers only their own. if (organization?.id) { params.append('custodian', organization.id); } if (patient && !isRoot) { params.append('subject', patient.id as string); } // The practice drawer lists the organization's own documents, whatever patient was selected. if (isRoot) { params.append('author', organization?.id as string); } if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/DocumentReference?${params.toString()}`, options); }); }, [get]); // The documents a current list shows: the search with this viewer's own saves and deletes laid over it once. const fetchDocumentReferences = useCallback(async (patient: Patient | undefined, organization: Organization | null | undefined, isRoot: boolean = false, options?: ReadOptions): Promise> => { const listScope: DocumentListScope = { isRoot, patientId: patient?.id, organizationId: organization?.id }; const searched = await searchDocumentReferencesAsIndexed(patient, organization, isRoot, options); return overlayHeldBundle('DocumentReference', searched, (document) => belongsToList(document, listScope)) as Bundle; }, [searchDocumentReferencesAsIndexed]); const fetchDocumentReferencesWithPolling = useCallback(async (patient: Patient | undefined, organization: Organization | null | undefined, isRoot: boolean = false, forceReload = false, awaitId?: string): Promise> => { const now = Date.now(); const cacheAge = now - lastFetchTimeRef.current; const cacheFresh = cacheAge < 5 * 60 * 1000; // The practice is part of a patient's scope: one patient seen at two practices has two lists. const practice = organization?.id ?? 'none'; const scope = isRoot ? `org:${practice}` : `org:${practice}:patient:${patient?.id}`; const listScope: DocumentListScope = { isRoot, patientId: patient?.id, organizationId: organization?.id }; listScopeRef.current = listScope; // Each searched bundle is overlaid once; the caller gets the same rows the list shows, archived ones included. const withHeld = (searched: Bundle) => { const started = searchStartedOf(searched); if (started !== undefined && !sameViewerSince(started)) return { list: [], bundle: { ...searched, entry: [] } as Bundle }; // Served as of a cutoff and not in full: shown as served, with nothing held laid over it or settled by it. const shown = isAsOfIncomplete(searched) ? unpackBundle(searched) : overlayHeld('DocumentReference', unpackBundle(searched), (document) => belongsToList(document, listScope), Date.now(), searchIsComplete(searched), searchStartedOf(searched)); return { list: shown.filter((r) => !isArchived(r)), bundle: { ...searched, entry: shown.map((resource) => ({ resource })) } as Bundle }; }; // A cached bundle answers only the scope it was loaded for; the practice drawer must never be // handed a patient's documents, nor a patient chart the practice's. if (!forceReload && !shouldReload && !writtenSince('DocumentReference', lastFetchTimeRef.current) && documentReferencesBundleRef.current && cacheFresh && bundleScopeRef.current === scope && generationRef.current === holdGeneration('DocumentReference')) { // A cache hit is a request too: it supersedes any slower one for another scope, and puts this // scope's list back if that one had already cleared it. latestRequestRef.current++; publish(generationRef.current); const cached = withHeld(documentReferencesBundleRef.current); setDocumentReferencesList(cached.list); return cached.bundle; } const request = ++latestRequestRef.current; const viewer = viewerEpoch(); const generation = holdGeneration('DocumentReference'); if (bundleScopeRef.current !== scope) { setDocumentReferencesList([]); } let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: DocumentReference[]; do { bundle = await searchDocumentReferencesAsIndexed(patient, organization, isRoot); entries = unpackBundle(bundle); // A caller that just wrote one names it, since `lastUpserted` set in the same turn is not yet visible here. const waitingFor = awaitId ?? lastUpserted; found = waitingFor ? entries.some(e => e.id === waitingFor) : true; if (!found && request === latestRequestRef.current) { await new Promise(resolve => setTimeout(resolve, delay)); } retries--; } while (!found && retries > 0 && request === latestRequestRef.current); // Superseded while it was in flight: hand the caller its answer, but leave the list and cache alone. Under // another viewer by now, the answer is empty. if (request !== latestRequestRef.current) { return viewer === viewerEpoch() ? bundle : { ...bundle, entry: [], ...(bundle.total === undefined ? {} : { total: 0 }) }; } const published = withHeld(bundle); setDocumentReferencesBundle(bundle); publish(generation); setDocumentReferencesList(published.list); setLastUpserted(null); setShouldReload(false); lastFetchTimeRef.current = now; generationRef.current = generation; bundleScopeRef.current = scope; setDocumentReferencesScope(scope); return published.bundle; }, [searchDocumentReferencesAsIndexed, unpackBundle, lastUpserted, shouldReload]); // A search the caller composes itself, unpacked, paged to the end and with the archived ones // dropped - the same treatment `fetchDocumentReferences` gives its own fixed query. It exists // because that one is the PAGE's list: it takes an organization, polls for a write to land and // replaces the cached bundle. A caller that wants the two documents belonging to one resource // wants none of that. const searchDocumentReferences = useCallback(async (params: URLSearchParams): Promise => { const bundle = await fetchAllPagesAsBundle((pageParam) => { const paged = new URLSearchParams(params); if (pageParam) paged.set('page', pageParam); return get>(`${API_URL}/DocumentReference?${paged.toString()}`); }); return unpackBundle(bundle).filter((documentReference) => !isArchived(documentReference)); }, [get, unpackBundle]); // The document as a currently valid snapshot holds it, or undefined: the newest list published for this viewer // with nothing held changed since, or a document this viewer has just created. const currentDocumentReference = useCallback((id: string | undefined): DocumentReference | undefined => { if (!id) return undefined; const generation = holdGeneration('DocumentReference'); const saved = savedRef.current.get(id); if (saved && saved.viewer === viewerEpoch() && saved.generation === generation) return saved.document; const current = publication.viewer === viewerEpoch() && publication.number === publicationRef.current && publication.generation === generation; return current ? documentReferencesList.find(doc => doc.id === id) : undefined; }, [documentReferencesList, publication]); const fetchDocumentReference = useCallback(async (id: string): Promise => { return currentDocumentReference(id) ?? get(`${API_URL}/DocumentReference/${id}`); }, [get, currentDocumentReference]); const fetchDocumentReferenceHistory = useCallback(async (id: string, options?: ReadOptions): Promise> => { return get>(`${API_URL}/DocumentReference/${id}/_history`, options); }, [get]); // Undefined when the save succeeded but this viewer's read withholds the document (SAVED_WITHHELD_MESSAGE). // `filingId`: the staff filing the document's files were issued under; `transport`: the save it belongs to. const createDocumentReference = useCallback(async (documentReference: DocumentReference, options: { filingId?: string; transport?: Partial } = {}): Promise => { const startedIn = startWrite(); const answer = await post(`${API_URL}/DocumentReference`, underFiling(documentReference, options.filingId), { ...options.transport }); options.transport?.admit?.(); if (isWithheldReceipt(answer)) return undefined; const created = answer as DocumentReference; const listScope = listScopeRef.current; // A save answered after the viewer changed is not put on the list now shown. if (created?.id && listScope && sameViewerSince(startedIn) && belongsToList(created, listScope)) { setDocumentReferencesList((shown) => [created, ...shown.filter((document) => document.id !== created.id)]); // Readable at once by id. The rest of the list keeps the generation it was published with, so rows a // write or an expiry has made stale are not revalidated by this save. savedRef.current.set(created.id, { document: created, viewer: viewerEpoch(), generation: holdGeneration('DocumentReference') }); } return created; }, [post]); const updateDocumentReference = useCallback(async (documentReference: DocumentReference, options: { filingId?: string; transport?: Partial } = {}): Promise => { const updated = await put(`${API_URL}/DocumentReference/${documentReference.id}`, underFiling(documentReference, options.filingId), { ...options.transport }); options.transport?.admit?.(); return isWithheldReceipt(updated) ? undefined : updated as DocumentReference; }, [put]); const deleteDocumentReference = useCallback(async (documentReference: DocumentReference): Promise => { const startedIn = startWrite(); const deleted = await del(`${API_URL}/DocumentReference/${documentReference.id}`); // Held with the whole document, so only a list it belongs to can settle the delete. holdDelete(documentReference, Date.now(), startedIn); return deleted; }, [del]); const archiveDocumentReference = useCallback(async (documentReference: DocumentReference, reason: ArchiveReason, note?: string): Promise => { const archived = await put(`${API_URL}/DocumentReference/${documentReference.id}`, applyArchive(documentReference, reason, note)); return archived; }, [put]); const transformToFhirDocumentReference = useCallback((item: any, patient?: Patient, organization?: Organization): DocumentReference => { const { status, docStatus, type, category, date, author, relatesTo, description, securityLabel, content, context } = item; const transformedAuthor = transformToReferenceCollection(author); const transformedRelatesTo: DocumentReferenceRelatesTo[] = relatesTo.map((relation: any) => ({ code: relation.code, target: relation.target })); let transformedContext: DocumentReferenceContext = { ...(context.encounter && context.encounter.length !== 0 && { encounter: transformToReferenceCollection(context.encounter) }), ...(context.event && context.event.length !== 0 && { event: transformToCodeableConcept(context.event) }), ...(context.facilityType && { facilityType: transformToCodeableConcept(context.facilityType) as CodeableConcept }), ...(context.practiceSetting && { practiceSetting: transformToCodeableConcept(context.practiceSetting) as CodeableConcept }), // What the document is ABOUT, when that is a resource rather than an encounter - the // Coverage a photograph of an insurance card was added under, for instance. It is the // field that makes a document sit BESIDE a resource without becoming a field ON it, so // the document outlives the plan it was filed under instead of dying with it. ...(context.related && context.related.length !== 0 && { related: transformToReferenceCollection(context.related) }) }; if (context?.period) { transformedContext = addFhirPeriodIfApplicable(transformedContext, context, 'period'); } let documentReference: DocumentReference = { resourceType: 'DocumentReference', status: status, ...(organization && { custodian: createReferenceObject('Organization', organization.id as string, organization.name) }), ...(patient && { subject: createReferenceObject('Patient', patient.id as string, formatFhirHumanName(patient?.name)) }), ...(!patient && organization && { author: [createReferenceObject('Organization', organization.id as string, organization.name)] }), ...(docStatus && { docStatus: docStatus }), ...(type && { type: transformToCodeableConcept(type) as CodeableConcept }), ...(category && category.length !== 0 && { category: transformToCodeableConcept(category) }), ...(date && { date: formatDateTime(date) }), ...(transformedAuthor && transformedAuthor.length !== 0 && { author: transformedAuthor }), ...(description && { description: description }), ...(securityLabel && securityLabel.length !== 0 && { securityLabel: transformToCodeableConcept(securityLabel) }), ...(transformedRelatesTo && transformedRelatesTo.length !== 0 && { relatesTo: transformedRelatesTo }), ...(Object.keys(transformedContext).length !== 0 && { context: transformedContext }), ...(content && content.length !== 0 && content.some((c: any) => c.attachment) && { content: content .filter((contentItem: any) => contentItem.attachment) .map((contentItem: any) => { const documentContent: DocumentReferenceContent = { attachment: contentItem.attachment as Attachment, ...(contentItem.format && { format: transformToCodeableConcept(contentItem.format) as CodeableConcept }) }; return documentContent; }) }) }; return documentReference; }, [ createReferenceObject, transformToCodeableConcept, transformToIdentifier, transformToReferenceCollection, addFhirPeriodIfApplicable, extractReferenceId, formatDateTime ]); const transformFromFhirDocumentReference = useCallback((documentReference: DocumentReference): any => { const { masterIdentifier, identifier, status, docStatus, type, category, date, author, authenticator, custodian, relatesTo, description, securityLabel, content, context } = documentReference; const formData = { masterIdentifier: masterIdentifier ? transformFromIdentifier([masterIdentifier])[0] : null, identifier: transformFromIdentifier(identifier ?? []), status: status, docStatus: docStatus ?? '', type: transformFromCodeableConcept(type), category: transformFromCodeableConcept(category ?? []), date: formatDateTimeForInput(date ?? ''), author: transformFromReferenceCollection(author ?? []), authenticator: authenticator ? { referenceType: authenticator.reference?.split('/')[0], reference: authenticator, display: authenticator.display } : null, custodian: custodian ? { referenceType: custodian.reference?.split('/')[0], reference: custodian, display: custodian.display } : null, relatesTo: (relatesTo ?? []).map((relation: DocumentReferenceRelatesTo) => ({ code: relation.code, target: relation.target })), description: description ?? '', securityLabel: transformFromCodeableConcept(securityLabel ?? []), content: (content ?? []).map((contentItem: DocumentReferenceContent) => ({ attachment: contentItem.attachment, format: transformFromCodeableConcept(contentItem.format) })), context: { encounter: transformFromReferenceCollection(context?.encounter ?? []), event: transformFromCodeableConcept(context?.event ?? []), facilityType: transformFromCodeableConcept(context?.facilityType), practiceSetting: transformFromCodeableConcept(context?.practiceSetting), // Read back what the write side now emits. A form that reads a resource, drops a field and // writes the result deletes that field, silently - and the field here is what keeps a // photograph of an insurance card beside the plan it belongs to. related: transformFromReferenceCollection(context?.related ?? []) } }; if (context?.period) { formData.context = addPeriodControlsIfApplicable(formData.context, context, 'period') as typeof formData.context; } return formData; }, [ transformFromCodeableConcept, transformFromIdentifier, transformFromReferenceCollection, addPeriodControlsIfApplicable, formatDateTimeForInput ]); const getDocumentReferenceDisplay = (documentReference: DocumentReference): string => { if (!documentReference) { return 'Unknown Document Reference'; } const description = documentReference.description || formatFhirCodeableConcept(documentReference.type) || 'Document'; // - 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(documentReference.status); const status = statusText ? ` ยท ${statusText}` : ''; let dateSuffix = ''; if (documentReference.date) { dateSuffix = ` (${formatDate(documentReference.date)})`; } return `${description}${status}${dateSuffix}`; }; return { documentReferencesBundle, documentReferencesScope, documentReferencesList, setDocumentReferencesList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchDocumentReferences, fetchDocumentReferencesWithPolling, searchDocumentReferences, fetchDocumentReference, currentDocumentReference, fetchDocumentReferenceHistory, createDocumentReference, updateDocumentReference, filings, deleteDocumentReference, archiveDocumentReference, transformToFhirDocumentReference, transformFromFhirDocumentReference, getDocumentReferenceDisplay }; }; const DocumentReferenceServiceContext = createContext | undefined>(undefined); export const DocumentReferenceServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const documentReferenceService = useDocumentReferenceService(); return ( {children} ); }; export const useDocumentReferenceServiceContext = () => { const context = useContext(DocumentReferenceServiceContext); if (context === undefined) { throw new Error('useDocumentReferenceServiceContext must be used within a DocumentReferenceServiceProvider'); } return context; };