import { useCallback } from 'react'; import { environment } from '../environments/environment.ts'; import { HttpError, useHttpService } from './HttpService.tsx'; import { useRecordsService, type RecordsRequest } from './RecordsService.tsx'; // The release steps of an incoming records request: build the packet, preview it, approve it, and send it. // Every step is decided on the server; each write carries the operation id and the version it was read at. // Every call belongs to one opening of one request at one practice: its signal cancels it once that opening is // retired, and its admit refuses to send it after that. const API_URL = environment.orgAuthorizationApi; export type RecordsCategory = | 'Condition' | 'AllergyIntolerance' | 'MedicationStatement' | 'MedicationRequest' | 'Immunization' | 'Observation' | 'DiagnosticReport' | 'Procedure' | 'Encounter' | 'ClinicalImpression' | 'DocumentReference' | 'CarePlan' | 'ServiceRequest' | 'Communication' | 'Consent'; /** The structured release's format, beside the PDF packet. */ export const STRUCTURED_FORMAT = 'application/fhir+json'; /** * The structured (FHIR) release the server built beside the packet: its exact file and what it does not carry, or why * none could be made. Absent from a packet built without one. */ export type RecordsStructuredSummary = | { format: typeof STRUCTURED_FORMAT; sha256: string; omissions: Array<{ where: string; reason: string }> } | { format: typeof STRUCTURED_FORMAT; refused: string }; export interface RecordsManifestSummary { manifestId: string; digest: string; completeness: 'available-through-cutoff' | 'incomplete'; cutoff?: string; /** Each included document's files: attached to the packet, or left out and why. */ files?: Array<{ reference: string; title: string; status: 'attached' | 'left-out'; reason?: string }>; categories: Array<{ category: RecordsCategory; status: 'read-whole' | 'truncated' | 'unavailable'; reason?: string }>; items: Array<{ resourceType: RecordsCategory; id: string; versionId?: string; association: 'included' | 'outside' | 'unavailable'; disposition: 'include' | 'withheld' | 'unavailable'; dispositionReason?: string; }>; structured?: RecordsStructuredSummary; } /** * What a request's pinned packet holds, read without previewing it: its identity, the request version it was read at, * and each chosen category with its read status and how many records it includes. Nothing names a record or a file. */ export interface RecordsPinnedScope { request: { requestId: string; version: number; state: string }; pin: { manifestId: string; digest: string; completeness?: RecordsManifestSummary['completeness']; cutoff?: string; categories: Array<{ category: RecordsCategory; status?: RecordsManifestSummary['categories'][number]['status']; included?: number }>; }; } /** * One fax attempt as the server recorded it. Intent: written before the vendor was called, with no answer recorded; * submitted: the vendor took it (not delivery); failed: certainly not sent; unconfirmed: the vendor did not say. */ export interface RecordsFaxAttempt { state: 'intent' | 'submitted' | 'failed' | 'unconfirmed'; vendorRef?: string; toFax?: string; } /** * One link attempt on a hand-over: prepared (made, but whether it was let out is not known), withheld (refused, with * why), or issued only where the server holds evidence of it. None of them is the recipient having the records. */ export interface RecordsHandOverLink { linkId?: string; outcome: 'prepared' | 'withheld' | 'issued'; at?: string; code?: string; message?: string; } /** The server's record that a manual hand-over was made: not a link, and not the recipient having the records. */ export interface RecordsManualReceipt { receiptId: string; kind: 'download' | 'print'; /** Present when the hand-over was of the structured release; absent for the PDF packet. */ format?: typeof STRUCTURED_FORMAT; at: string; by?: string; links?: RecordsHandOverLink[]; } /** A recorded hand-over as the server reads it back: what became of its link, and any confirmation of delivery. */ export interface RecordsHandOverStatus { receipt: RecordsManualReceipt; /** unknown: the server could not resolve this hand-over's attempt history, so nothing is claimed about its link. */ linkState: 'owed' | 'withheld' | 'prepared' | 'issued' | 'unknown'; lastLink?: RecordsHandOverLink; /** Its link attempts, newest first, bounded by the server; truncated says older ones are not listed. */ attempts?: RecordsHandOverLink[]; attemptsTruncated?: boolean; /** False when the server could not read the whole history; the link state is then unknown. */ historyComplete?: boolean; confirmation?: { note?: string; at?: string; by?: string }; } /** Who a release went to, as its own pinned manifest proves; never the request's details as they read now. */ export type RecordsReleasedTarget = | { proof: 'proved'; recipient: { name: string; method: string; fax?: string } } | { proof: 'not-recorded' } | { proof: 'unavailable' }; /** A dispatched release as its receipts record it: the way it went and its exact attempt or hand-over, or held. */ export type RecordsReleased = | { channel: 'fax'; attempt: { receiptId: string; attemptId: string; toFax: string; manifestDigest: string; state: string }; target: RecordsReleasedTarget } | { channel: 'manual'; kind: 'download' | 'print'; receiptId: string; manifestDigest: string; target: RecordsReleasedTarget } | { channel: 'held'; reason: 'both' | 'none' | 'electronic' }; /** * A hand-over the server recorded but gave no link for. The request is the one it wrote, when the answer carries it; * the reason is the server's own. Asked again under the same write, the server decides the link afresh. */ export class HandOverRecordedWithoutLink extends Error { constructor(readonly receipt: RecordsManualReceipt, readonly reason: string, readonly request?: RecordsRequest) { super(reason); this.name = 'HandOverRecordedWithoutLink'; } } /** A refused hand-over answer that carries a receipt: the hand-over was recorded before the link was refused. */ const recordedWithoutLink = (error: unknown, requestId: string): HandOverRecordedWithoutLink | null => { if (!(error instanceof HttpError)) return null; const body = error.body as { receipt?: Partial; request?: RecordsRequest; message?: unknown; linkWithheld?: unknown } | undefined; const receipt = body?.receipt; if (!receipt || typeof receipt.receiptId !== 'string' || (receipt.kind !== 'download' && receipt.kind !== 'print') || typeof receipt.at !== 'string') return null; const reason = typeof body?.message === 'string' && body.message ? body.message : typeof body?.linkWithheld === 'string' ? body.linkWithheld : 'No link was given.'; const written = body?.request?.requestId === requestId ? body.request : undefined; return new HandOverRecordedWithoutLink(receipt as RecordsManualReceipt, reason, written); }; export interface ReleaseWrite { operationId: string; expectedVersion: number } /** Which opening a call belongs to: the practice it is sent for, and how it is cancelled and refused once retired. */ export interface ReleaseCallOwner { organizationId: string; signal: AbortSignal; admit: () => void } /** * The longest one release call is waited on: just above the server's own 24 seconds, by which every step answers, * plus room for the token, the proof and the network. Past it the call is given up, never resent by itself. */ export const RELEASE_CALL_MS = 30_000; /** A release call given up on its deadline: whether the server applied it is not known, so it is retried as sent. */ export class ReleaseCallUnanswered extends Error { constructor() { super('That step got no answer in time. Try again; it will not be applied twice.'); this.name = 'ReleaseCallUnanswered'; } } /** One call held to its deadline: its own signal ends it then, or when the opening is retired, whichever is first. */ const bounded = async (owner: ReleaseCallOwner, call: (sent: ReleaseCallOwner) => Promise): Promise => { const own = new AbortController(); const retire = () => own.abort(); owner.signal.addEventListener('abort', retire, { once: true }); let timer: ReturnType | undefined; const late = new Promise((_, reject) => { timer = setTimeout(() => { own.abort(); reject(new ReleaseCallUnanswered()); }, RELEASE_CALL_MS); }); const sent: ReleaseCallOwner = { organizationId: owner.organizationId, signal: own.signal, admit: () => { owner.admit(); if (own.signal.aborted) throw new DOMException('This release call was given up.', 'AbortError'); }, }; try { return await Promise.race([call(sent), late]); } finally { clearTimeout(timer); owner.signal.removeEventListener('abort', retire); } }; /** One athena send attempt as the server records it (infra contract.ts RecordsAthenaReceipt). */ export interface RecordsAthenaReceipt { kind: 'athena-document'; receiptId: string; requestId: string; organizationId: string; attemptId: string; state: 'intent' | 'submitted' | 'delivered' | 'failed' | 'unconfirmed' | 'readback-mismatch'; revision: number; until: string; at: string; vendorRef?: number; refusal?: string; vendorSaid?: string; proof?: 'object' | 'content-unverified' | 'exact' | 'staff-attested'; } /** A send registration a recipient may be chosen from; it proves no working credential. */ export interface AthenaRegistration { id: string; vendor: string; environment: string; practices: Array<{ practiceId: number }>; } /** The confirmed athena recipient, exactly as staff saw athena's match for it. */ export interface AthenaRecipientTuple { name: string; registrationId: string; practiceId: number; departmentId: number; documentSubclass: string; vendorPatientId: number; queryDigest: string; } /** * What an athena step came to. A receipt is that attempt's outcome, whatever the status; owed means not settled. A * refusal carries no attempt. Unanswered means nothing is known: a lost, malformed or another attempt's answer. */ export type AthenaAnswer = /** current: the request read afterwards in the same step when the answer carried none; never the request at admission. */ | { kind: 'receipt'; status: number; receipt: RecordsAthenaReceipt; request?: RecordsRequest; current?: RecordsRequest; owed?: string; message?: string } | { kind: 'request'; request: RecordsRequest } | { kind: 'refused'; status: number; code?: string; message: string } | { kind: 'unanswered' }; const ATTEMPT_STATES = new Set(['intent', 'submitted', 'delivered', 'failed', 'unconfirmed', 'readback-mismatch']); const isAthenaReceipt = (value: unknown, requestId: string, organizationId: string): value is RecordsAthenaReceipt => { const receipt = value as Partial | undefined; return Boolean(receipt) && receipt!.kind === 'athena-document' && typeof receipt!.attemptId === 'string' && ATTEMPT_STATES.has(String(receipt!.state)) && Number.isSafeInteger(receipt!.revision) && receipt!.requestId === requestId && receipt!.organizationId === organizationId; }; /** The server's statement that this very write started the attempt it names; the only binding a bare receipt has. */ const startedBy = (value: unknown, requestId: string, write: ReleaseWrite | undefined): string | undefined => { const sent = value as { operationId?: unknown; requestId?: unknown; channel?: unknown; attemptId?: unknown } | undefined; return write && sent && sent.operationId === write.operationId && sent.requestId === requestId && sent.channel === 'athena-document' && typeof sent.attemptId === 'string' ? sent.attemptId : undefined; }; /** * Reads one athena answer for the attempt it was asked about: expected names that attempt; without it (a send), the * request the answer carries, or the server's statement of what this write started, must name the receipt's attempt. * A retired opening's abort is passed on, never decoded. */ const decodeAthena = (requestId: string, organizationId: string, expected: string | undefined, write: ReleaseWrite | undefined, status: number, body: unknown): AthenaAnswer => { const answer = (body ?? {}) as { receipt?: unknown; request?: RecordsRequest; sent?: unknown; owed?: unknown; message?: unknown; code?: unknown }; const request = answer.request?.requestId === requestId ? answer.request : undefined; if (answer.request && !request) return { kind: 'unanswered' }; if (answer.receipt !== undefined) { if (!isAthenaReceipt(answer.receipt, requestId, organizationId)) return { kind: 'unanswered' }; const attempt = expected ?? request?.dispatch?.attemptId ?? startedBy(answer.sent, requestId, write); if (!attempt || answer.receipt.attemptId !== attempt || (request?.dispatch && request.dispatch.attemptId !== answer.receipt.attemptId)) return { kind: 'unanswered' }; return { kind: 'receipt', status, receipt: answer.receipt, ...(request ? { request } : {}), ...(typeof answer.owed === 'string' ? { owed: answer.owed } : {}), ...(typeof answer.message === 'string' ? { message: answer.message } : {}), }; } if (status >= 400 && typeof answer.message === 'string') { return { kind: 'refused', status, ...(typeof answer.code === 'string' ? { code: answer.code } : {}), message: answer.message }; } return status < 400 && request ? { kind: 'request', request } : { kind: 'unanswered' }; }; /** The scope a follow-up read carries: the step's own practice, signal and admission, never a fresh allowance. */ const scopeOf = (sent: ReleaseCallOwner) => ({ organizationId: sent.organizationId, signal: sent.signal, admit: sent.admit }); const registrationOf = (value: unknown): AthenaRegistration | undefined => { const entry = value as Partial | undefined; if (!entry || typeof entry.id !== 'string' || typeof entry.vendor !== 'string' || typeof entry.environment !== 'string' || !Array.isArray(entry.practices)) return undefined; if (!entry.practices.every((practice) => Number.isSafeInteger(practice?.practiceId) && practice.practiceId > 0)) return undefined; return { id: entry.id, vendor: entry.vendor, environment: entry.environment, practices: entry.practices.map((practice) => ({ practiceId: practice.practiceId })) }; }; const at = (requestId: string, step: string) => `${API_URL}/records/requests/${encodeURIComponent(requestId)}/${step}`; const sentFor = (owner: ReleaseCallOwner) => ({ organizationId: owner.organizationId, signal: owner.signal, admit: owner.admit }); export const useRecordsReleaseService = () => { const { get, post } = useHttpService(); const { getRequest } = useRecordsService(); /** * One athena step, held to one deadline from its call through any follow-up read: a receipt that comes without its * request has the request read in the same step, under the step's own practice, signal and admission. */ const athenaStep = useCallback( (requestId: string, owner: ReleaseCallOwner, expected: string | undefined, write: ReleaseWrite | undefined, step: string, body: unknown) => bounded(owner, async (sent): Promise => { let status = 200; let answered: unknown; try { answered = await post(at(requestId, step), body, sentFor(sent)); } catch (failure) { if (owner.signal.aborted) throw failure; if (!(failure instanceof HttpError)) return { kind: 'unanswered' }; status = failure.status; answered = failure.body; } const answer = decodeAthena(requestId, owner.organizationId, expected, write, status, answered); if (answer.kind !== 'receipt' || answer.request) return answer; try { return { ...answer, current: await getRequest(requestId, scopeOf(sent)) }; } catch (failure) { if (owner.signal.aborted) throw failure; return answer; } }), [post, getRequest], ); /** The request as it stands, read as its own bounded step for this opening; it neither sends nor extends any write. */ const readRequest = useCallback( async (requestId: string, owner: ReleaseCallOwner) => bounded(owner, (sent) => getRequest(requestId, scopeOf(sent))), [getRequest], ); const buildPacket = useCallback( async (requestId: string, write: ReleaseWrite, categories: RecordsCategory[], owner: ReleaseCallOwner) => bounded(owner, (sent) => post<{ request: RecordsRequest; manifest: RecordsManifestSummary }>(at(requestId, 'manifest'), { ...write, categories }, sentFor(sent))), [post], ); /** * A link to the exact bytes that will be sent, valid for a few minutes, and what the packet holds: the PDF packet, or * the structured release beside it. */ const previewPacket = useCallback( async (requestId: string, owner: ReleaseCallOwner, format: 'pdf' | 'fhir' = 'pdf') => bounded(owner, (sent) => get<{ url: string; manifest: RecordsManifestSummary }>( at(requestId, 'preview') + (format === 'fhir' ? '?format=fhir' : ''), sentFor(sent))), [get], ); /** What the pinned packet holds (a 409 access-changed carries the chosen categories alone). */ const readPinnedScope = useCallback( async (requestId: string, owner: ReleaseCallOwner) => bounded(owner, (sent) => get(at(requestId, 'manifest'), sentFor(sent))), [get], ); /** * Approves the PDF packet, and the structured release beside it only when its reviewed file is named. The packet * alone is sent exactly as before, so a retry of an earlier approval is the same operation. */ const approveRelease = useCallback( async (requestId: string, write: ReleaseWrite, owner: ReleaseCallOwner, structuredSha256?: string) => bounded(owner, (sent) => post<{ request: RecordsRequest }>(at(requestId, 'approve'), structuredSha256 ? { ...write, formats: ['pdf', 'fhir'], structuredSha256 } : write, sentFor(sent))), [post], ); /** * The hand-over is recorded with its receipt; the link may be owed, and is asked for again under the same write. * A refusal that carries the receipt is a hand-over recorded with no link, never a step that did not happen. */ const handOver = useCallback( async (requestId: string, write: ReleaseWrite, kind: 'download' | 'print', owner: ReleaseCallOwner, format: 'pdf' | 'fhir' = 'pdf') => { try { return await bounded(owner, (sent) => post<{ request: RecordsRequest; receipt?: RecordsManualReceipt; url?: string; linkOwed?: string }>( at(requestId, 'hand-over'), { ...write, kind, ...(format === 'fhir' ? { format } : {}) }, sentFor(sent))); } catch (failure) { throw recordedWithoutLink(failure, requestId) ?? failure; } }, [post], ); /** The request's recorded hand-overs, newest first, with no link: what a reopened or second view reads. */ const readHandOvers = useCallback( async (requestId: string, owner: ReleaseCallOwner) => bounded(owner, (sent) => get<{ request: RecordsRequest; handOvers: RecordsHandOverStatus[]; released?: RecordsReleased }>(at(requestId, 'hand-over'), sentFor(sent))), [get], ); /** A fresh link for a recorded hand-over, decided again by the server; never a second hand-over. */ const linkForHandOver = useCallback( async (requestId: string, receiptId: string, owner: ReleaseCallOwner) => { try { return await bounded(owner, (sent) => post<{ request: RecordsRequest; receipt: RecordsManualReceipt; url: string }>( at(requestId, 'hand-over/link'), { receiptId }, sentFor(sent))); } catch (failure) { throw recordedWithoutLink(failure, requestId) ?? failure; } }, [post], ); /** What the server recorded of the attempt; owed says its answer is not settled, and it is never sent again. */ const sendFax = useCallback( async (requestId: string, write: ReleaseWrite, owner: ReleaseCallOwner) => bounded(owner, (sent) => post<{ request: RecordsRequest; receipt?: RecordsFaxAttempt; owed?: string; message?: string }>(at(requestId, 'fax'), write, sentFor(sent))), [post], ); /** * The note says how the person knows the recipient has the records; it is kept with the confirmation as its evidence. * A manual hand-over is confirmed by its receipt, so the confirmation can only be of that hand-over. */ const confirmDelivered = useCallback( async (requestId: string, write: ReleaseWrite, note: string, owner: ReleaseCallOwner, handOverReceiptId?: string) => bounded(owner, (sent) => post<{ request: RecordsRequest }>(at(requestId, 'delivered'), { ...write, note, ...(handOverReceiptId ? { handOverReceiptId } : {}) }, sentFor(sent))), [post], ); /** The registrations a recipient may be chosen from: a preflight with no selection. */ const athenaRegistrations = useCallback( async (requestId: string, owner: ReleaseCallOwner) => { const answer = await bounded(owner, (sent) => post<{ registrations?: unknown }>(at(requestId, 'athena/preflight'), {}, sentFor(sent))); return (Array.isArray(answer?.registrations) ? answer.registrations : []).flatMap((entry) => registrationOf(entry) ?? []); }, [post], ); /** A practice's departments and, for a chosen department, athena's match; nothing is written. */ const athenaPreflight = useCallback( async (requestId: string, selection: { registrationId: string; practiceId: number; departmentId?: number }, owner: ReleaseCallOwner) => bounded(owner, (sent) => post<{ departments: Array<{ departmentId: number; name: string; clinicals?: string }>; match?: unknown; queryDigest?: string }>( at(requestId, 'athena/preflight'), selection, sentFor(sent))), [post], ); /** Records the recipient athena's match still proves; a resend of the same write is answered from its own operation. */ const athenaConfirmRecipient = useCallback( async (requestId: string, write: ReleaseWrite, tuple: AthenaRecipientTuple, owner: ReleaseCallOwner) => bounded(owner, (sent) => post<{ request: RecordsRequest; recipient: unknown }>(at(requestId, 'athena/recipient'), { ...write, ...tuple }, sentFor(sent))), [post], ); const athenaSend = useCallback( async (requestId: string, write: ReleaseWrite, owner: ReleaseCallOwner) => athenaStep(requestId, owner, undefined, write, 'athena/send', write), [athenaStep], ); /** Reads back the attempt the request names; a settled one answers without asking athena. */ const athenaReadBack = useCallback( async (requestId: string, attemptId: string, owner: ReleaseCallOwner) => athenaStep(requestId, owner, attemptId, undefined, 'athena/read-back', {}), [athenaStep], ); /** The server alone decides whether the attempt can be attested; its refusal is shown as it says it. */ const athenaAttest = useCallback( async (requestId: string, write: ReleaseWrite, note: string, attemptId: string, owner: ReleaseCallOwner) => athenaStep(requestId, owner, attemptId, undefined, 'athena/attest', { ...write, note }), [athenaStep], ); return { buildPacket, previewPacket, readPinnedScope, approveRelease, handOver, readHandOvers, linkForHandOver, sendFax, confirmDelivered, athenaRegistrations, athenaPreflight, athenaConfirmRecipient, athenaSend, athenaReadBack, athenaAttest, readRequest, }; };