import { useCallback } from 'react'; import { environment } from '../environments/environment.ts'; import { HttpError, useHttpService } from './HttpService.tsx'; import { isSaveRetired } from '../utils/saveOperation'; import type { StoredAnnotationState } from '../components/DicomViewport/annotationKeying.ts'; // apiUrl, NOT fhirClinicalApi, AND NOT fhirFoundationApi. `/R4/imaging/*` - including this // annotations route - is declared only in api.yaml, on the gateway that resolves to // environment.apiUrl. That is the same base ImagingFrameService.tsx and ImagingUploadService.tsx // already use for the other /R4/imaging/* routes. // // environment.fhirClinicalApi is a DIFFERENT gateway (03-fhir-clinical.yaml) serving FHIR resource // types such as ImagingStudy; it declares no imaging path at all. // // environment.fhirFoundationApi is a THIRD gateway (01-fhir-foundation.yaml) serving // DocumentReference - the resource type the SERVER stores annotations as, in HealthLake. That is // irrelevant to the client: this service never talks FHIR directly, it talks the imaging routes, // and the server is what translates to/from DocumentReference. Do not let "annotations are stored // as a DocumentReference" pull this onto fhirFoundationApi. // // Pointing at either wrong gateway does not fail like a wrong host: API Gateway returns // 403 {"message":"Missing Authentication Token"} for an unmatched resource BEFORE the authorizer // runs, which reads exactly like an auth failure. This precise mistake has shipped before and // survived a long review chain, because the symptom does not point at the cause. // // Already ends in /R4 - do not append another. const API_URL = environment.apiUrl; const annotationsUrl = (studyId: string): string => `${API_URL}/imaging/annotations?study=${encodeURIComponent(studyId)}`; /** Which saved document a view saw, at which version: what its next save or promotion names. */ export interface AnnotationEdition { documentId: string; versionId: string; } /** * The saved state and its edition. `edition` is null only for the server's complete answer that nothing is saved * yet; `annotations` is left `unknown` because fromStored validates its shape defensively. */ export interface SavedAnnotations { annotations: unknown; edition: AnnotationEdition | null; } /** * What a save came to. `changed`: another window or session saved first, and nothing was written. `unavailable`: * the server could not read the saved state whole, and nothing was written. `unknown`: it may have been written. */ export type SaveAnswer = | { kind: 'saved'; edition: AnnotationEdition } | { kind: 'changed' } | { kind: 'unavailable' } | { kind: 'unknown' }; /** The operation a request belongs to: admitted just before sending, cancelled with it, sent in its practice. */ export interface OwnedAnnotationRequest { admit?: () => void; signal?: AbortSignal; organizationId?: string; } const isEdition = (value: unknown): value is AnnotationEdition => typeof value === 'object' && value !== null && typeof (value as AnnotationEdition).documentId === 'string' && (value as AnnotationEdition).documentId.length > 0 && typeof (value as AnnotationEdition).versionId === 'string' && (value as AnnotationEdition).versionId.length > 0; /** The saved-state envelope the viewer writes and reads: version 1 with a list of annotations. */ const isUsableState = (value: unknown): boolean => typeof value === 'object' && value !== null && !Array.isArray(value) && (value as { version?: unknown }).version === 1 && Array.isArray((value as { annotations?: unknown }).annotations); const codeOf = (error: unknown): unknown => (error instanceof HttpError ? (error.body as { code?: unknown } | undefined)?.code : undefined); export const useImagingAnnotationService = () => { const { get, put } = useHttpService(); // Refuses an answer without a usable edition: saved state with no edition is not "nothing saved". const fetchAnnotations = useCallback(async (studyId: string, owned?: OwnedAnnotationRequest): Promise => { const response = await get<{ annotations?: unknown; edition?: unknown }>(annotationsUrl(studyId), owned); const edition = response?.edition; if (edition === null && response.annotations === null) return { annotations: null, edition: null }; if (!isEdition(edition)) throw new Error('Saved measurements came back without the edition they are at.'); // State this viewer cannot read is not an empty one: opened as empty, the next edit would replace it. if (!isUsableState(response.annotations)) throw new Error('Saved measurements came back in a form this viewer cannot read.'); return { annotations: response.annotations, edition: { documentId: edition.documentId, versionId: edition.versionId } }; }, [get]); // The server derives author, subject and type itself; the body is the state and the edition it was made from. // Never throws for an answer: only a request its operation retired before sending rejects. const saveAnnotations = useCallback(async ( studyId: string, state: StoredAnnotationState, basedOn: AnnotationEdition | null, owned?: OwnedAnnotationRequest, ): Promise => { try { const response = await put<{ edition?: unknown }>(annotationsUrl(studyId), { annotations: state, basedOn }, owned); return isEdition(response?.edition) ? { kind: 'saved', edition: { documentId: response.edition.documentId, versionId: response.edition.versionId } } : { kind: 'unknown' }; } catch (error) { if (isSaveRetired(error)) throw error; if (error instanceof HttpError && error.status === 409 && codeOf(error) === 'ANNOTATIONS_CHANGED') return { kind: 'changed' }; if (error instanceof HttpError && error.status === 503 && codeOf(error) === 'IMAGING_DISCOVERY_UNAVAILABLE') return { kind: 'unavailable' }; return { kind: 'unknown' }; } }, [put]); return { fetchAnnotations, saveAnnotations }; };