import { useCallback } from 'react'; import { Bundle, Observation, Practitioner } from 'fhir/r4'; import { environment } from '../environments/environment.ts'; import { measurementFigure } from '../utils/measurementFigure.ts'; import { formatFhirHumanName } from '../utils/fhirHumanName.tsx'; import { HttpError, useHttpService } from './HttpService.tsx'; import type { AnnotationEdition, OwnedAnnotationRequest } from './ImagingAnnotationService.tsx'; import { isSaveRetired } from '../utils/saveOperation.ts'; // Two different API Gateway hosts are involved here, and mixing them up does not fail loudly. // // promoteMeasurements writes through the imaging routes, declared only on the gateway that // resolves to environment.apiUrl - the same base ImagingAnnotationService.tsx, ImagingFrameService.tsx // and ImagingUploadService.tsx already use for every other /R4/imaging/* route. // // fetchPromotedMeasurements reads back what that write produced, and what it produced is a plain // Observation - served by the FHIR clinical gateway, environment.fhirClinicalApi, the same base // ObservationService.tsx and ImagingStudyService.tsx use. environment.apiUrl declares no // Observation path at all, and environment.fhirClinicalApi declares no imaging path at all. // // Pointing either call at the other host 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 rather than a routing mistake. // // Both already end in /R4 - do not append another. const IMAGING_API_URL = environment.apiUrl; const OBSERVATION_API_URL = environment.fhirClinicalApi; // Must match measurementObservation.ts's MEASUREMENT_IDENTIFIER_SYSTEM on the server exactly - // it is the identifier the server stamps onto every promoted Observation, and the only reliable // way to tell a promoted measurement from an unpromoted one. Nothing about promotion state is // ever inferred from anything kept client-side. const MEASUREMENT_IDENTIFIER_SYSTEM = 'http://akashic.health/fhir/NamingSystem/imaging-measurement'; // The one clinical choice a caller of this route may state: which angle a drawn Angle measurement // is. Not a system, not a code, not a display - those live only in the server's own fixed concept // table (measurementCoding.ts on the server) and this is a selector over it, never a substitute for // it. Must match that table exactly; a name outside it is refused rather than charted. export type AngleType = 'cobb' | 'acetabular' | 'other'; // One measurement as the caller states it: which drawing, and - for an angle - which angle it is. // Everything else the charted resource carries (the value, the code, the subject, the performer, // the date) is derived server-side from the caller's own stored annotations and identity, which is // what makes the charted figure provably the one that was measured rather than merely asserted. export interface PromotionRequest { annotationUid: string; angleType?: AngleType; } // Every refusal from this route names the measurement it refused, in a body shaped // `{ message, annotationUid }` - an unrecognised type, an angle promoted with no type, a type given // to a tool that is not an angle, and one uid stated twice with two different types are four of // several cases that do this. Carrying the uid as its own field, not folded only into the message // text, is what lets a caller point at the row that caused the refusal rather than showing a // rejection nobody looking at the panel can trace to anything on screen. Not every failure from this // call names a measurement - a network failure or a refusal unrelated to any one entry has none to // name - so the field stays optional rather than being invented. export class PromotionRefusalError extends Error { constructor(message: string, readonly annotationUid?: string, readonly code?: string) { super(message); this.name = 'PromotionRefusalError'; } } // The nine wire states a promotion effect can carry (contract ยง4) - never a value, never an // attribution, only what happened to one measurement's write. export type PromotionClinicalState = | 'not-dispatched' | 'admission-unknown' | 'unknown' | 'charted' | 'amended' | 'unchanged' | 'refused' | 'superseded' | 'conflict'; export type PromotionEvidenceState = 'none' | 'pending' | 'owed'; export interface PromotionEffect { annotationUid: string; clinical: PromotionClinicalState; evidence: PromotionEvidenceState; // Present, and non-empty, on `not-dispatched` - the token that authorises Continue. Absent only in // an answer to a named recovery, where it means the operation was proved never admitted. withdrawal?: string; } export interface PromotionOperationStatus { operation: string; effects: PromotionEffect[]; } // Every non-refusal outcome of a promotion attempt, never void and never a thrown refusal. export type PromotionResult = | { kind: 'settled'; operation: string; effects: PromotionEffect[] } | { kind: 'incomplete'; operation: string; effects: PromotionEffect[] } | { kind: 'unknown'; operation: string } | { kind: 'retired' }; const ALLOWED_CLINICAL: PromotionClinicalState[] = [ 'not-dispatched', 'admission-unknown', 'unknown', 'charted', 'amended', 'unchanged', 'refused', 'superseded', 'conflict' ]; const ALLOWED_EVIDENCE: PromotionEvidenceState[] = ['none', 'pending', 'owed']; const TERMINAL_CLINICAL: PromotionClinicalState[] = ['charted', 'amended', 'unchanged']; // Structural validation only - never trusted from the wire without it, since a 200 that is // contradictory or malformed must read as `unknown`, never as a settled success. const isValidEffect = (effect: unknown, recovery = false): effect is PromotionEffect => { if (typeof effect !== 'object' || effect === null) return false; const candidate = effect as Record; if (typeof candidate.annotationUid !== 'string') return false; if (typeof candidate.clinical !== 'string' || !ALLOWED_CLINICAL.includes(candidate.clinical as PromotionClinicalState)) return false; if (typeof candidate.evidence !== 'string' || !ALLOWED_EVIDENCE.includes(candidate.evidence as PromotionEvidenceState)) return false; const hasWithdrawal = typeof candidate.withdrawal === 'string' && candidate.withdrawal.length > 0; if (candidate.withdrawal !== undefined && !hasWithdrawal) return false; if (candidate.clinical === 'not-dispatched' && !hasWithdrawal && !recovery) return false; return true; }; const isSettledEnvelope = (body: unknown, operation: string): body is { operation: string; effects: PromotionEffect[] } => { if (typeof body !== 'object' || body === null) return false; const candidate = body as Record; if (candidate.operation !== operation) return false; if (!Array.isArray(candidate.effects)) return false; // Unchanged writes nothing and so owes no evidence; charted and amended always owe it. return candidate.effects.every(effect => isValidEffect(effect) && TERMINAL_CLINICAL.includes(effect.clinical) && (effect.clinical === 'unchanged' || effect.evidence === 'owed')); }; const isIncompleteEnvelope = (body: unknown, operation: string, recovery = false): body is { operation: string; effects: PromotionEffect[] } => { if (typeof body !== 'object' || body === null) return false; const candidate = body as Record; if (candidate.operation !== operation) return false; if (!Array.isArray(candidate.effects)) return false; return candidate.effects.every(effect => isValidEffect(effect, recovery)); }; const isOperationsEnvelope = (body: unknown): body is { operations: PromotionOperationStatus[] } => { if (typeof body !== 'object' || body === null) return false; const candidate = body as Record; if (!Array.isArray(candidate.operations)) return false; return candidate.operations.every(op => { if (typeof op !== 'object' || op === null) return false; const entry = op as Record; return typeof entry.operation === 'string' && Array.isArray(entry.effects) && entry.effects.every(effect => isValidEffect(effect)); }); }; const decodeSettled = (body: unknown, operation: string): PromotionResult => isSettledEnvelope(body, operation) ? { kind: 'settled', operation, effects: (body as { effects: PromotionEffect[] }).effects } : { kind: 'unknown', operation }; // Retired only when the owner itself withdrew the request (admit refused it, or its signal fired). // Any other failure of a request still owned - network, unreadable answer - may have written: unknown. const decodeError = (error: unknown, operation: string, owned?: OwnedAnnotationRequest, recovery = false): PromotionResult => { if (error instanceof HttpError) { const body = error.body as { message?: string; annotationUid?: string; code?: string } | undefined; if (error.status === 409 && body?.code === 'PROMOTION_INCOMPLETE') { return isIncompleteEnvelope(body, operation, recovery) ? { kind: 'incomplete', operation, effects: (body as { effects: PromotionEffect[] }).effects } : { kind: 'unknown', operation }; } if (isRefusalStatus(error.status) && typeof body?.message === 'string') { throw new PromotionRefusalError(body.message, body.annotationUid, body.code); } return { kind: 'unknown', operation }; } if (isSaveRetired(error) || owned?.signal?.aborted) return { kind: 'retired' }; return { kind: 'unknown', operation }; }; // A REFUSAL IS A DECISION THE SERVER MADE, AND EVERY ONE THIS ROUTE MAKES IS A 4xx. A server failure // is not one, and it cannot be told apart by the body: the shared error handler renders every // unhandled failure as `{ message: 'Internal server error', error }`, which carries a `message` // exactly like a refusal does. Reading a refusal off the presence of that field alone would put those // three words in front of the clinician INSTEAD of this feature's own wording - and that wording is // the only thing that says a batch can fail partway, leaving some measurements charted and some not. // Told "Internal server error" beside a panel still listing everything as unsaved, a clinician has // been given no reason to look at the chart before trying again. const isRefusalStatus = (status: number): boolean => status >= 400 && status < 500; export interface PromotedMeasurement { annotationUid: string; observationId: string; issued?: string; performerDisplay?: string; // Who the chart holds as performer when it stores no name for them: read by id, never shown as one. performerReference?: string; // THE FIGURE THE CHART ACTUALLY HOLDS, not the one currently drawn on the image. The two are the // same right up until the clinician nudges the caliper, and after that a panel that shows only the // live figure beside the words "saved to chart" is asserting something false about the record. // Formatted the same way the live figure is, so identical values read identically and a real // difference is the only thing that looks like one. Absent when the charted resource carries no // readable quantity at all, which is a shape the promotion path does not produce. chartedLabel?: string; // THE CONCEPT THE CHART ACTUALLY HOLDS, for the same reason chartedLabel exists above: the type // ticked in the browser and the type actually saved diverge the moment a clinician re-promotes an // angle under a different choice, and a panel that only ever showed the local selection would go // on asserting the earlier one after the chart had already moved past it. Read straight off the // Observation's own first coding display, so a charted Cobb angle reads as "Cobb angle" regardless // of what is ticked right now. Absent when the resource carries no coding display at all. codeDisplay?: string; // THE SAME CONCEPT, IDENTIFIED THE WAY A CONCEPT IS IDENTIFIED. The display above is prose written // for a clinician to read; a system and a code are what say WHICH concept it is, and they are what // a caller must compare against if it wants to know whether the chart holds the concept it thinks // it does. Recognising a charted concept by its display text would make a server-side re-wording - // a change the server is entitled to make without changing anything that was charted - turn every // already-charted angle into one the caller can no longer name, silently withdrawing the notice // that says the clinician's choice disagrees with the record. Absent when the resource's first // coding carries neither, which the promotion path does not produce. codeSystem?: string; code?: string; } const measurementsUrl = (studyId: string): string => `${IMAGING_API_URL}/imaging/measurements?study=${encodeURIComponent(studyId)}`; // A single-value token search - identifier={system}|{value}, plus subject - because that is the // only Observation search shape this codebase has anywhere proven against HealthLake: it is // exactly what the server's own findChartedObservation (promoteMeasurements.ts) sends when it // looks up one promoted Observation to amend. A trailing-pipe "system, any value" search has no // such proof behind it, and the risk is not cosmetic: if HealthLake parsed that as "system, value // equals empty string" instead of "any value in this system", the search would return zero rows // forever, indistinguishable from nothing having been promoted yet - inviting a clinician to // promote something that is already charted. Worse, the usual tell for a misread search parameter // (HealthLake injecting an OperationOutcome into the bundle) is documented elsewhere in this // codebase as firing only when a parameter NAME is unrecognised, not when a supported parameter's // VALUE is misparsed, so that failure mode would not even announce itself. One proven search per // annotation uid, run in parallel and combined, avoids the shape entirely; the uid list this is // called with is exactly what is on screen, so the fan-out stays small. const chartedObservationUrl = (patientId: string, annotationUid: string): string => { const params = new URLSearchParams(); params.append('identifier', `${MEASUREMENT_IDENTIFIER_SYSTEM}|${annotationUid}`); params.append('subject', `Patient/${patientId}`); return `${OBSERVATION_API_URL}/Observation?${params.toString()}`; }; // HealthLake silently ignores a search parameter it does not recognise and returns the full, // unfiltered result set rather than erroring, but it also injects an OperationOutcome entry into // the bundle when it does - the one tell this codebase has for "that search cannot be trusted" // (see AppointmentReminderSweep/index.ts and inbound-sms/patientLookup.ts, which both fail their // whole operation closed on exactly this). Cast rather than compared directly against Observation's // literal resourceType, because TypeScript correctly refuses to compare two string literal types // that can never be equal - the same cast those two call sites use for the same reason. const hasOperationOutcome = (bundle: Bundle): boolean => (bundle.entry ?? []).some( entry => (entry.resource as { resourceType?: string } | undefined)?.resourceType === 'OperationOutcome' ); // The charted figure, read straight off the resource the chart returned. // // A single-valued measurement carries valueQuantity; an elliptical region carries components instead, // and the FIRST of them is its area - the same figure the viewer's own row shows for that tool, which // is what makes the two comparable at a glance. The later components (mean and deviation of pixel // value) are not shown here because there is nothing on the row to compare them against; a figure with // no counterpart reads as a discrepancy rather than as information. const chartedFigure = (observation: Observation): string | undefined => { const quantity = observation.valueQuantity ?? observation.component?.[0]?.valueQuantity; if (typeof quantity?.value !== 'number' || !Number.isFinite(quantity.value)) return undefined; return measurementFigure(quantity.value, quantity.unit ?? quantity.code ?? ''); }; // Re-checked against the patient, study and specific annotation uid actually asked for, rather // than trusted from the search filter alone - the same defensive re-check the server itself // performs when it looks up one promoted Observation to amend (promoteMeasurements.ts's // findChartedObservation). This is a second, independent defence on top of the OperationOutcome // check above: that check only catches HealthLake ignoring the identifier parameter outright, not // a search that runs but comes back broader than the one value it was given. A result whose own // identifier does not carry the exact uid that was searched for is dropped rather than trusted. const toPromotedMeasurement = ( observation: Observation, studyId: string, patientId: string, expectedAnnotationUid: string ): PromotedMeasurement | null => { if (!observation.id) return null; if (observation.subject?.reference !== `Patient/${patientId}`) return null; if (!(observation.derivedFrom ?? []).some(reference => reference.reference === `ImagingStudy/${studyId}`)) return null; const carriesRequestedIdentifier = (observation.identifier ?? []).some( candidate => candidate.system === MEASUREMENT_IDENTIFIER_SYSTEM && candidate.value === expectedAnnotationUid ); if (!carriesRequestedIdentifier) return null; // The head of the coding list, which is the whole of it for a resource this route wrote: the // promotion handler builds exactly one coding. Read once so the display and the pair identifying // it can never come from two different entries. const coding = observation.code?.coding?.[0]; return { annotationUid: expectedAnnotationUid, observationId: observation.id, issued: observation.issued, performerDisplay: observation.performer?.[0]?.display, performerReference: observation.performer?.[0]?.reference, chartedLabel: chartedFigure(observation), codeDisplay: coding?.display, codeSystem: coding?.system, code: coding?.code }; }; const practitionerIdOf = (reference: string | undefined): string | undefined => reference?.match(/^Practitioner\/([^/]+)$/)?.[1]; export const useImagingMeasurementService = () => { const { get, post } = useHttpService(); // The server derives the value, the clinical code, the subject, the performer and the date // itself from the caller's stored annotations and identity - the body carries an annotation uid // and, for an angle, which angle it is, and nothing else. The type is new and allowed because it // is a clinical choice only a human can make; it is still a selector over the server's own fixed // table, never a system, a code or a display the caller states outright. A client that sent a // measured value or an attribution would defeat the reason this route exists instead of // POST /R4/Observation: that the charted value is provably equal to what was measured, not // merely asserted by whoever is holding the browser. // // A refusal is rethrown as PromotionRefusalError rather than left as the generic HttpError the // shared post() helper raises, so a caller can read the offending annotationUid as its own field // instead of having to parse it back out of a flattened message string. // `basedOn` is the saved edition the viewer's flush admitted: the server charts that edition's values and no later one. const promoteMeasurements = useCallback(async ( studyId: string, measurements: PromotionRequest[], basedOn: AnnotationEdition, operation: string, owned?: OwnedAnnotationRequest, ): Promise => { try { const body = await post(measurementsUrl(studyId), { operation, measurements, basedOn }, owned); return decodeSettled(body, operation); } catch (error) { return decodeError(error, operation, owned); } }, [post]); // GET-only: the caller's own unresolved operations for this study, never a write. Rejects a body it // cannot read: an empty list would clear holds on work whose outcome was never proved. const promotionStatus = useCallback(async (studyId: string, owned?: OwnedAnnotationRequest): Promise<{ operations: PromotionOperationStatus[] }> => { const body = owned ? await get(measurementsUrl(studyId), owned) : await get(measurementsUrl(studyId)); if (!isOperationsEnvelope(body)) throw new Error('Promotion status came back in a form this viewer cannot read.'); return body; }, [get]); // Carries forward exactly the not-dispatched tokens for one operation - the server refuses a // stale or mismatched token rather than re-planning. Effects with no token name uids only: a // read-only named recovery, answered for that operation, and fenced if it was never admitted. const continuePromotion = useCallback(async ( studyId: string, operation: string, effects: { annotationUid: string; withdrawal?: string }[], owned?: OwnedAnnotationRequest ): Promise => { const recovery = effects.every(effect => effect.withdrawal === undefined); try { const body = await post(measurementsUrl(studyId), { operation, continue: true, effects }, owned); return decodeSettled(body, operation); } catch (error) { return decodeError(error, operation, owned, recovery); } }, [post]); // Takes the annotation uids currently on screen rather than discovering them itself, because // there is no proven "give me everything promoted for this study" search shape to discover // them with (see chartedObservationUrl above) - only a proven per-uid lookup. The caller // always has this list already: it is the same set of drawings it is about to render rows for. // // Throws rather than resolving to an empty array when any search's result cannot be trusted, // because an empty array here reads as "nothing promoted yet" to a caller deciding whether to // offer promotion - and offering promotion against a chart state that could not actually be // confirmed is how a measurement already on the chart gets promoted a second time. Callers // should treat a rejection here the same way they treat any other unavailable chart state: // disable promotion and say so, never fall back to rendering as though nothing were charted. // A name only for the practitioner asked about; any other answer or a failed read names nobody. const readPractitionerName = useCallback(async (id: string): Promise => { try { const found = await get<{ practitioner?: Practitioner } | Practitioner>(`${environment.fhirIdentityApi}/Practitioner/${id}`); const practitioner = 'practitioner' in found ? found.practitioner : found as Practitioner; return practitioner?.id === id ? formatFhirHumanName(practitioner.name) || undefined : undefined; } catch { return undefined; } }, [get]); const fetchPromotedMeasurements = useCallback(async ( studyId: string, patientId: string, annotationUids: string[] ): Promise => { if (annotationUids.length === 0) return []; const results = await Promise.all(annotationUids.map(async annotationUid => ({ annotationUid, // SEARCH-BOUNDED: each search filters on ONE annotation uid via `identifier=|` // plus the subject - an annotation uid identifies a single drawing, so at most one charted // Observation can match. The fan-out is one request per uid, and the bound is per request. bundle: await get>(chartedObservationUrl(patientId, annotationUid)) }))); // A page that names a next page was not read to its end, so its silence is not "not charted". if (results.some(({ bundle }) => (bundle.link ?? []).some((link) => link.relation === 'next'))) { console.error(`fetchPromotedMeasurements: a chart-status search for study ${studyId} did not fit one page; refusing to report it as complete`); throw new Error('Could not confirm which measurements are already charted; chart status is unavailable.'); } if (results.some(({ bundle }) => hasOperationOutcome(bundle))) { console.error(`fetchPromotedMeasurements: HealthLake ignored a search parameter for study ${studyId}; refusing to report an unfiltered chart-state result`); throw new Error('Could not confirm which measurements are already charted; chart status is unavailable.'); } const measurements = results .flatMap(({ annotationUid, bundle }) => (bundle.entry ?? []) .map(entry => entry.resource) .filter((resource): resource is Observation => resource != null && resource.resourceType === 'Observation') .map(observation => toPromotedMeasurement(observation, studyId, patientId, annotationUid))) .filter((measurement): measurement is PromotedMeasurement => measurement !== null); const unnamed = [...new Set(measurements .filter(measurement => !measurement.performerDisplay) .map(measurement => practitionerIdOf(measurement.performerReference)) .filter((id): id is string => Boolean(id)))]; const names = new Map(await Promise.all(unnamed.map(async id => [id, await readPractitionerName(id)] as const))); return measurements.map(measurement => { const name = measurement.performerDisplay ? undefined : names.get(practitionerIdOf(measurement.performerReference) ?? ''); return name ? { ...measurement, performerDisplay: name } : measurement; }); }, [get, readPractitionerName]); return { promoteMeasurements, fetchPromotedMeasurements, promotionStatus, continuePromotion }; };