import type { ReadOptions } from '../hooks/anchorView/readOptions'; import { claimReadBacks, holdWrite, isHeld, markSearchStarted, overlayHeldBundle, readBackSettled, refersToPatient, sameViewerSince, startSearch, writtenSince } from '../utils/writeThroughHold'; import { useState, useCallback, createContext, useContext, ReactNode, useEffect, useRef } from 'react'; import { Bundle, QuestionnaireResponse, Patient, Questionnaire } from 'fhir/r4'; import { environment } from '../environments/environment.ts'; import { useFhirServiceContext } from './FhirService.tsx'; import { useHttpService } from './HttpService.tsx'; import { usePatientServiceContext } from './PatientService.tsx'; import { applyArchive, isArchived, type ArchiveReason } from '../utils/archivedResource'; import { statusLabel } from '../utils/fhirStatusVocabulary'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; import { searchPageSize } from '../utils/pageSizeOverride'; import { useCacheOwner } from '../utils/cacheOwner'; import { questionnaireIdFromReference } from '../utils/questionnaireReference'; const API_URL = environment.fhirClinicalApi; // A held answer belongs to a list asked for one questionnaire only when it answers that questionnaire. const answersQuestionnaire = (response: QuestionnaireResponse, questionnaire?: Questionnaire): boolean => !questionnaire?.id || questionnaireIdFromReference(response.questionnaire) === questionnaire.id; export const useQuestionnaireResponseService = () => { const { get, post, put, delete: del } = useHttpService(); const { patientState } = usePatientServiceContext(); const { formatDateTime, formatDateTimeForInput, formatDateTimeForDisplay, unpackBundle, transformToReferenceCollection, transformFromReferenceCollection, extractReferenceId } = useFhirServiceContext(); const [questionnaireResponsesList, setQuestionnaireResponsesList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [questionnaireResponsesBundle, setQuestionnaireResponsesBundle] = useState | null>(null); const questionnaireResponsesBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); const cachedQuestionnaireRef = useRef(null); const extensionUrls = { firstCreated: 'https://akashichealthrecords.com/fhir/extensions/first-created', itemType: 'http://hl7.org/fhir/StructureDefinition/questionnaire-itemType' }; useEffect(() => { questionnaireResponsesBundleRef.current = questionnaireResponsesBundle; }, [questionnaireResponsesBundle]); // WHOSE RECORDS ARE THESE? The reset below fires only when there is NO patient, which moving // between two charts never is - so the previous patient's bundle survived the switch and was // served as a cache hit, with no request and nothing on screen to say the list was not theirs. const cacheOwner = useCacheOwner('QuestionnaireResponse', () => { setLastUpserted(null); setQuestionnaireResponsesBundle(null); setQuestionnaireResponsesList([]); lastFetchTimeRef.current = 0; }); useEffect(() => { if (!patientState) { setQuestionnaireResponsesBundle(null); setQuestionnaireResponsesList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); const fetchQuestionnaireResponses = useCallback(async (patient: Patient, questionnaire?: Questionnaire, deliberate = false, options?: ReadOptions): Promise> => { const started = startSearch(); const searched = await fetchAllPagesAsBundle((pageParam) => { let params = new URLSearchParams(); params.append('_count', searchPageSize('100')); params.append('subject', patient.id as string); // params.append('status', 'completed'); if (questionnaire) { params.append('questionnaire', questionnaire.id as string); } if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/QuestionnaireResponse?${params.toString()}`, options); }); // A search started under an earlier viewer touches nothing of the next viewer's: no written id is taken as // read, none is claimed and no read by id starts. Its caller gets nothing. if (!sameViewerSince(started)) return { ...searched, entry: [], ...(searched.total === undefined ? {} : { total: 0 }) }; markSearchStarted(searched, started); // This patient's answers written but not yet read back are read by id now, so they show before the search has // them. A deliberate refresh asks again for one given up on. const answers = (searched.entry ?? []).map((entry) => entry.resource).filter((resource): resource is QuestionnaireResponse => Boolean(resource)); await Promise.all(claimReadBacks('QuestionnaireResponse', answers, patient.id, deliberate).map(async (readBack) => { try { holdWrite(await get(`${API_URL}/QuestionnaireResponse/${readBack.id}`), Date.now(), readBack.token); readBackSettled('QuestionnaireResponse', readBack, true); } catch (error) { readBackSettled('QuestionnaireResponse', readBack, false); console.error('A written answer still could not be read back.', error); } })); return overlayHeldBundle('QuestionnaireResponse', searched, (resource) => refersToPatient(resource, patient.id) && answersQuestionnaire(resource, questionnaire)) as Bundle; }, [get]); // `deliberate` also offers again an answer given up on; a reload forced for another reason does not. const fetchQuestionnaireResponsesWithPolling = useCallback(async (patient: Patient, questionnaire?: Questionnaire, forceReload = false, deliberate = forceReload): Promise> => { const now = Date.now(); const cacheAge = now - lastFetchTimeRef.current; const cacheFresh = cacheAge < 5 * 60 * 1000; // cacheOwner.isFor is the load-bearing clause: a fresh bundle belonging to the PREVIOUS // patient satisfies every other condition here and would be returned as a hit. // The cache answers only the questionnaire it was read for, as well as only its patient. if (!forceReload && !shouldReload && !writtenSince('QuestionnaireResponse', lastFetchTimeRef.current) && questionnaireResponsesBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(patient.id) && cachedQuestionnaireRef.current === (questionnaire?.id ?? null)) { return questionnaireResponsesBundleRef.current; } const operation = cacheOwner.begin(); let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: QuestionnaireResponse[]; do { bundle = await fetchQuestionnaireResponses(patient, questionnaire, deliberate); entries = unpackBundle(bundle).filter((r) => !isArchived(r)); found = lastUpserted ? entries.some(e => e.id === lastUpserted) || isHeld('QuestionnaireResponse', 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); setQuestionnaireResponsesBundle(bundle); setQuestionnaireResponsesList(entries.filter((r) => !isArchived(r))); setLastUpserted(null); setShouldReload(false); lastFetchTimeRef.current = now; cachedQuestionnaireRef.current = questionnaire?.id ?? null; // Stamped WITH the data, never before it - on a throw the owner must still name the list // that is actually in state. cacheOwner.claim(patient.id, operation); return bundle; }, [fetchQuestionnaireResponses, unpackBundle, lastUpserted, shouldReload]); const fetchQuestionnaireResponse = useCallback(async (id: string): Promise => { const cachedResponse = cacheOwner.isCurrent(cacheOwner.claimed) ? questionnaireResponsesList.find(response => response.id === id) : undefined; if (cachedResponse) { return cachedResponse; } return get(`${API_URL}/QuestionnaireResponse/${id}`); }, [get, questionnaireResponsesList]); const fetchQuestionnaireResponseHistory = useCallback(async (id: string, count: number = 20, options?: ReadOptions): Promise> => { return get>(`${API_URL}/QuestionnaireResponse/${id}/_history?_count=${count}`, options); }, [get]); const createQuestionnaireResponse = useCallback(async (resource: QuestionnaireResponse): Promise => { resource.extension = resource.extension || []; resource.extension.push({ url: extensionUrls.firstCreated, valueInstant: new Date().toISOString() }); // - the subject goes in the QUERY STRING, not only in the body. `POST /R4/QuestionnaireResponse` // 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(resource.subject) as string); return post(`${API_URL}/QuestionnaireResponse?${params.toString()}`, resource); }, [post, extensionUrls.firstCreated, extractReferenceId]); const updateQuestionnaireResponse = useCallback(async (resource: QuestionnaireResponse): Promise => { return put(`${API_URL}/QuestionnaireResponse/${resource.id}`, resource); }, [put]); const deleteQuestionnaireResponse = useCallback(async (resource: QuestionnaireResponse): Promise => { return del(`${API_URL}/QuestionnaireResponse/${resource.id}`); }, [del]); const archiveQuestionnaireResponse = useCallback(async (resource: QuestionnaireResponse, reason: ArchiveReason, note?: string): Promise => { await put(`${API_URL}/QuestionnaireResponse/${resource.id}`, applyArchive(resource, reason, note)); }, [put]); const transformToFhirQuestionnaireResponse = useCallback((questionnaireResponse: any): QuestionnaireResponse => { const { status, encounter, item, authored, basedOn, partOf, meta } = questionnaireResponse; const usCoreQuestionnaireresponseProfile: string = 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-questionnaireresponse'; const transformedBasedOn = transformToReferenceCollection(basedOn); const transformedPartOf = transformToReferenceCollection(partOf); return { resourceType: 'QuestionnaireResponse', status: status, item: item, authored: formatDateTime(authored), meta: { ...meta, profile: [usCoreQuestionnaireresponseProfile] }, ...(encounter && { encounter: encounter }), ...(transformedBasedOn && transformedBasedOn.length !== 0 && { basedOn: transformedBasedOn }), ...(transformedPartOf && transformedPartOf.length !== 0 && { partOf: transformedPartOf }), }; }, [transformToReferenceCollection, formatDateTime]); const transformFromFhirQuestionnaireResponse = useCallback((questionnaireResponse: QuestionnaireResponse) => { const { meta, status, item, subject, encounter, authored, basedOn, partOf } = questionnaireResponse; return { meta: meta, status: status, item: item, subject: subject, encounter: encounter, authored: formatDateTimeForInput(authored ?? ''), basedOn: transformFromReferenceCollection(basedOn ?? []), partOf: transformFromReferenceCollection(partOf ?? []) }; }, [transformFromReferenceCollection, formatDateTimeForInput]); const getQuestionnaireResponseDisplay = (questionnaireResponse: QuestionnaireResponse, formTitle?: string): string => { if (!questionnaireResponse) { return 'Unknown Questionnaire Response'; } const title = formTitle?.trim() || 'Questionnaire Response'; // - 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(questionnaireResponse.status); const status = statusText ? ` ยท ${statusText}` : ''; let dateSuffix = ''; if (questionnaireResponse.authored) { dateSuffix = ` (${formatDateTimeForDisplay(questionnaireResponse.authored)})`; } return `${title}${status}${dateSuffix}`; }; return { // WHO THE CACHED LIST IS FOR, so a consumer can ask whether it belongs to the patient on // screen rather than trusting an app-wide context with no patient in its name. cachedForPatientId: cacheOwner.ownerId, questionnaireResponsesBundle, questionnaireResponsesList, setQuestionnaireResponsesList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, extensionUrls, fetchQuestionnaireResponses, fetchQuestionnaireResponsesWithPolling, fetchQuestionnaireResponse, fetchQuestionnaireResponseHistory, createQuestionnaireResponse, updateQuestionnaireResponse, deleteQuestionnaireResponse, archiveQuestionnaireResponse, transformToFhirQuestionnaireResponse, transformFromFhirQuestionnaireResponse, getQuestionnaireResponseDisplay }; }; const QuestionnaireResponseServiceContext = createContext | undefined>(undefined); export const QuestionnaireResponseServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const questionnaireResponseService = useQuestionnaireResponseService(); return ( {children} ); }; export const useQuestionnaireResponseServiceContext = () => { const context = useContext(QuestionnaireResponseServiceContext); if (context === undefined) { throw new Error('useQuestionnaireResponseServiceContext must be used within a QuestionnaireResponseServiceProvider'); } return context; };