import type { Observation } from 'fhir/r4'; import { environment } from '../environments/environment'; import { createSendRecovery, scopeDigest } from './messaging/sendRecovery'; import { mayHaveBeenWritten } from './fetchFailureMessage'; import { isSaveTimedOut } from './saveOperation'; /** * One Observation save carries one operation id from its first send to its confirmed answer, so a resend is never a * second reading. The id is held in this tab under a digest of viewer, acted-for person, practice, patient and editor, * never the reading, until it is settled; the same editor reopened by the same person checks it first. */ /** Saved, but this viewer may not see the record now. Still saved: never a reason to create it again. */ export interface WithheldSave { state: 'committed'; withheld: true; } export const isWithheldSave = (value: unknown): value is WithheldSave => (value as Partial | null)?.state === 'committed' && (value as Partial).withheld === true; /** What the server knows of one operation. */ export type OperationStanding = | { state: 'committed'; resource?: Observation; withheld: boolean } // `continuation`: what an explicit finish must present after the server fenced it; held only while it is shown. | { state: 'pending'; observation?: Observation; withheld: boolean; continuation?: string } | { state: 'refused'; reason?: string } // Not the server's: no operation under this id for this viewer (never admitted), or an answer that is not one. | { state: 'unknown' }; export const operationStandingOf = (value: unknown): OperationStanding => { const answer = value as { state?: unknown; resource?: unknown; observation?: unknown; reason?: unknown; withheld?: unknown; continuation?: unknown } | null; const withheld = answer?.withheld === true; const observation = (candidate: unknown) => (candidate as Observation | null)?.resourceType === 'Observation' ? candidate as Observation : undefined; if (answer?.state === 'committed') return { state: 'committed', resource: observation(answer.resource), withheld }; if (answer?.state === 'pending') { const continuation = typeof answer.continuation === 'string' && UUID_V4.test(answer.continuation) ? answer.continuation : undefined; return { state: 'pending', observation: observation(answer.observation), withheld, ...(continuation ? { continuation } : {}) }; } if (answer?.state === 'refused') return { state: 'refused', reason: typeof answer.reason === 'string' ? answer.reason : undefined }; return { state: 'unknown' }; }; const failureOf = (error: unknown) => error as { status?: unknown; body?: unknown } | null | undefined; const bodyOf = (error: unknown) => failureOf(error)?.body as { outcome?: unknown; reason?: unknown } | null | undefined; /** Our answer that the save may or may not have landed, or a sent save past its deadline; its id is kept and checked, never replaced. */ export const operationUnconfirmed = (error: unknown): boolean => mayHaveBeenWritten(error) || (isSaveTimedOut(error) && error.mayHaveSaved) || (failureOf(error)?.status === 503 && bodyOf(error)?.outcome === 'unconfirmed'); /** Nothing was written: the server fenced this operation, and only a check then an explicit finish can resume it. */ export const operationNotYetAdmitted = (error: unknown): boolean => failureOf(error)?.status === 503 && bodyOf(error)?.outcome === 'not-saved'; /** The id was already used for different content: nothing was written, and this save cannot go under it. */ export const operationReused = (error: unknown): boolean => failureOf(error)?.status === 409 && bodyOf(error)?.reason === 'operation-reused'; export const OPERATION_REUSED_MESSAGE = 'This save was already used for a different reading. Nothing was saved. Enter it as a new reading.'; export const OPERATION_NOT_SAVED_MESSAGE = 'Nothing was saved yet. Check this save to finish it as entered, or enter a new reading instead.'; export const OPERATION_UNAVAILABLE_MESSAGE = 'This save could not be prepared in this tab, so nothing was sent. Reload the page and try again.'; /** One operation, for its status (GET) or its finish (POST, carrying only a presented continuation): named in the query. */ export const observationOperationUrl = (base: string, operationId: string, patientId: string): string => `${base}/Observation-operation?subject=${encodeURIComponent(`Patient/${patientId}`)}&operation=${encodeURIComponent(operationId)}`; const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; export const mintOperationId = (): string => { const id = crypto.randomUUID().toLowerCase(); if (!UUID_V4.test(id)) throw new Error('No operation id could be made.'); return id; }; /** Who is saving, into what: the parts of the digest an operation is held under. */ export interface OperationScope { account: string | undefined; relatedPersonId: string | undefined; subjectPatientId: string | undefined; practice: string | undefined; patient: string; editor: string; } export const operationScopeDigest = (scope: OperationScope): Promise => scopeDigest(['observation-create', scope.account ?? null, scope.relatedPersonId ?? null, scope.subjectPatientId ?? null, scope.practice ?? null, scope.patient, scope.editor]); const tabStorage = (): Storage | undefined => { try { return window.sessionStorage; } catch { return undefined; } }; /** This tab's unsettled Observation saves. Its own record, apart from message sends. */ export const observationOperations = () => createSendRecovery(tabStorage(), `${environment.localStoragePrefix}_observationOperation`);