import { useCallback, useRef } from 'react'; import type { Patient } from 'fhir/r4'; import { environment } from '../environments/environment.ts'; import { HttpError, useHttpService } from './HttpService.tsx'; import type { ReferralRequestScope } from '../utils/referralRequestScope'; // Records requests live on the org authorization API. The shapes mirror the server contract // (infra utils/records/contract.ts); the server decides every transition and permission. const API_URL = environment.orgAuthorizationApi; // A read made for an opening: admitted before it is sent, cancelled when it retires, and sent under its practice. const scopedRead = (scope?: ReferralRequestScope) => (scope ? { admit: scope.admit, signal: scope.signal, organizationId: scope.organizationId } : {}); /** Where a list or history read goes on from: the server's continuation, sent back as it came. */ export interface RecordsPageOptions { cursor?: string } /** One page of a queue. More may exist past it; truncated without a cursor is a partial page that cannot be read on. */ export interface RecordsQueuePage { requests: RecordsRequest[]; truncated: boolean; consistency: 'eventual'; nextCursor?: string; } /** One page of a request's history, oldest version first. */ export interface RecordsHistoryPage { history: RecordsHistoryEntry[]; truncated: boolean; nextCursor?: string; } const cursorParam = (page: RecordsPageOptions | undefined, joiner: '?' | '&') => (page?.cursor ? joiner + 'cursor=' + encodeURIComponent(page.cursor) : ''); export type RecordsDirection = 'incoming' | 'outgoing'; export type RecordsRequestState = | 'received' | 'in-progress' | 'awaiting-approval' | 'approved' | 'delivering' | 'delivery-unconfirmed' | 'delivered' | 'draft' | 'awaiting-records' | 'records-received' | 'filed' | 'cancelled'; export type RecordsScope = 'practice' | 'all'; export interface RecordsBasis { kind: 'treatment' | 'patient-directed' | 'authorization' | 'other'; note?: string; instrument?: { reference: string; versionId: string }; } export interface RecordsParty { name: string; organization?: string; fax?: string; email?: string; address?: string; } /** A recipient staff verify by hand: the only kind the generic details write accepts. */ export interface RecordsHandRecipient extends RecordsParty { verifiedBy: string; verifiedAt: string; method: 'fax' | 'download' | 'print'; } export type RecordsRecipient = RecordsHandRecipient; /** An athena recipient as the request reads it (infra contract.ts AthenaRecipient); set only by its own confirmation. */ export interface AthenaRecipientRead extends RecordsParty { method: 'athena-document'; verifiedBy: string; verifiedAt: string; registrationId: string; registrationDigest: string; practiceId: number; practiceAuthorityDigest: string; departmentId: number; documentSubclass: string; autoclose: false; patient: { vendorPatientId: number; queryDigest: string; score: number; returned: number; confirmedBy: string; confirmedAt: string }; } /** What a request's recipient may read as; a write still takes only a hand recipient. */ export type RecordsRecipientRead = RecordsHandRecipient | AthenaRecipientRead; export interface RecordsRequest { organizationId: string; requestId: string; direction: RecordsDirection; state: RecordsRequestState; version: number; createdAt: string; createdBy: string; updatedAt: string; updatedBy: string; patient?: { id: string; matchedBy: string; matchedAt: string }; counterpart: RecordsParty; recipient?: RecordsRecipientRead; scope: RecordsScope; basis?: RecordsBasis; dueAt?: string; claim?: { by: string; until: string }; manifest?: { manifestId: string; digest: string }; /** The electronic attempt this request sent, as a pointer only: its outcome is read from the attempt itself. */ dispatch?: { channel: 'athena-document' | 'epic-hl7-document'; attemptId: string; receiptSk: string }; /** Evidence a step still owes, by step and operation, until it is recorded. */ evidenceOwed?: Array<{ step: string; operationId: string; since: string; attempts: number }>; /** deliverables: the sha256 of the exact bytes approved, by format; absent from an approval made before formats. */ approval?: { manifestDigest: string; approvedBy: string; approvedAt: string; policyVersion: string; deliverables?: Record }; intakeIds?: string[]; lastOperationId: string; } export interface RecordsWriteResult { request: RecordsRequest; operation: { operationId: string; outcome: 'applied' | 'replayed' }; } export interface RecordsHistoryEntry { version: number; action: string; state: RecordsRequestState; by: string; at: string; } export type RecordsRequestAction = 'claim' | 'release-claim' | 'start' | 'update-details' | 'send-request' | 'cancel'; export interface RecordsDetailsChange { counterpart?: RecordsParty; recipient?: Omit; scope?: RecordsScope; basis?: RecordsBasis; dueAt?: string; } export interface NewRecordsRequest { direction: RecordsDirection; counterpart: RecordsParty; scope?: RecordsScope; basis?: RecordsBasis; dueAt?: string; } /** A received original on an outgoing request (infra utils/records/intake.ts). */ export interface RecordsIntake { intakeId: string; requestId: string; channel: string; state: 'awaiting-upload' | 'received' | 'filed'; createdAt: string; createdBy: string; receiptId?: string; source?: { versionId: string; sha256: string }; /** Records retrieved through a connection: which, and what was read and kept. */ connectionId?: string; retrieval?: import('./RecordsConnectionsService.tsx').RecordsRetrievalSummary; /** A retrieval's own operation, choice and request version; unrecoverable when it was kept before they were. */ operationId?: string; operation?: 'unrecoverable'; choice?: { connectionId: string; externalPatient?: string; types?: string[] }; baseline?: number; /** What a retrieval promised to keep, and whether it was ever observed kept; promised is not kept. */ promisedSha256?: string; original?: 'promised' | 'observed'; } /** A request's originals; complete only when every page was read at the one request version it names. */ export interface RecordsIntakeList { intakes: RecordsIntake[]; complete: boolean; requestVersion?: number; } const INTAKE_PAGES = 10; export type ImportKind = 'AllergyIntolerance' | 'Condition' | 'MedicationStatement' | 'MedicationRequest' | 'Immunization' | 'Observation' | 'Procedure'; export type ImportAssertion = 'present' | 'absent' | 'uncertain'; export type UncertainMark = 'text' | 'date' | 'dosage' | 'value' | 'reaction' | 'recognition' | 'assertion'; /** * What the original could not establish; prescriber-unavailable: an order whose prescriber it does not name; * security-label-unread: a security label the chart does not recognize and did not apply. */ export type ImportLimit = 'prescriber-unavailable' | 'security-label-unread'; export interface ImportCandidateFields { text: string; assertion: ImportAssertion; date?: string; dosage?: string; value?: string; reaction?: string; } /** One item read from an original, waiting for a person (infra utils/records/importContract.ts). */ export interface ImportCandidate { candidateId: string; kind: ImportKind; fields: ImportCandidateFields; uncertain: UncertainMark[]; structured: boolean; limits?: ImportLimit[]; /** The sender's restriction labels the chart honors, written onto the chart with the item. */ restrictions?: { system: string; code: string }[]; /** OCR: the lines it was read from. Structured: its address at the system it came from, and that system's version. */ source: { lines?: { page: number; line: number; text: string; confidence: number }[]; item?: string; version?: string }; decision: 'pending' | 'accepted' | 'declined'; decidedBy?: string; decidedAt?: string; commit?: { resourceType: ImportKind; id: string; state: 'committing' | 'committed' | 'evidence-owed' | 'rejected'; reason?: string; /** Earlier attempts that were refused, oldest first; kept after a later attempt succeeds. */ refusals?: { operationId: string; reason: string }[]; }; /** The record service refused its write, and the server admits asking to try adding it again. */ retryable?: boolean; version: number; /** What this proposal says, as offered; a decision names it so a changed proposal is never decided unseen. */ digest: string; possibleDuplicates?: string[]; /** Chart items an earlier import made from the same source item, as they stand now. */ alreadyImported?: PriorImport[]; /** For an item with a source address: whether the chart was read completely enough to say what it holds. */ priorCheck?: 'complete' | 'unknown'; } /** A chart item an earlier import made from the same source item (infra importContract.ts PriorImport). */ export interface PriorImport { resource: string; sameVersion: boolean; editedHere: boolean; /** Its version here now; an acknowledgement names it, and without it none can. */ localVersion?: string; /** This copy lacks a restriction the received source now carries; the existing copy is unchanged. */ missingRestrictions?: true; } export interface RecordsImport { importId: string; intakeId: string; adapter: 'fhir-bundle' | 'ocr'; state: 'recognizing' | 'ready' | 'failed'; attempt: number; version: number; updatedAt: string; failure?: string; sourcePatient?: { name?: string; birthDate?: string }; counts?: { candidates: number; skipped: Record; droppedUngrounded: number; truncated: boolean }; /** false: read under an earlier mapping, so the same original may be read again. */ current?: boolean; } export interface CandidateDecision { operationId: string; expectedVersion: number; digest: string; /** retry: try adding again an item the record service refused, only where the server marks it retryable. */ decision: 'accept' | 'decline' | 'retry'; edits?: Partial> & { assertion?: ImportAssertion }; confirmed?: UncertainMark[]; /** Each copy changed here that the reviewer looked at, at the version they saw. */ acknowledged?: { resource: string; version: string }[]; } /** A write refused because someone wrote first, or the step does not apply; carries where it stands now. */ export const recordsConflict = (error: unknown): { code: string; message: string; current?: RecordsRequest } | null => { if (!(error instanceof HttpError) || error.status !== 409) return null; const body = error.body as { code?: string; message?: string; current?: RecordsRequest } | undefined; return body?.code ? { code: body.code, message: body.message ?? '', current: body.current } : null; }; export const newOperationId = (): string => crypto.randomUUID(); /** A patient as the Records lookup names them: identity alone, and whether this practice holds their care. */ export interface RecordsPatientCandidate { id: string; name: string; birthDate?: string; gender?: Patient['gender']; careHere: boolean; } const asPatient = (candidate: RecordsPatientCandidate): Patient => ({ resourceType: 'Patient', id: candidate.id, name: [{ use: 'official', text: candidate.name }], ...(candidate.birthDate ? { birthDate: candidate.birthDate } : {}), ...(candidate.gender ? { gender: candidate.gender } : {}), }); /** The opening a call belongs to: admitted before it is sent and before its answer is used, cancelled when it retires, pinned to its practice. */ export interface RecordsCallScope { admit?: () => void; signal?: AbortSignal; organizationId?: string; } /** scopeOf: the opening every import call made now belongs to, read when the call is made. */ export const useRecordsService = (scopeOf?: () => RecordsCallScope | undefined) => { const { get, post } = useHttpService(); const scopeRef = useRef(scopeOf); scopeRef.current = scopeOf; const scoped = (scope?: RecordsCallScope): RecordsCallScope => ({ ...scopeRef.current?.(), ...scope }); const listRequests = useCallback( async (direction: RecordsDirection, states: RecordsRequestState[], scope?: ReferralRequestScope, page?: RecordsPageOptions) => get( `${API_URL}/records/requests?direction=${direction}&state=${states.join(',')}${cursorParam(page, '&')}`, scopedRead(scope), ), [get], ); const getRequest = useCallback( async (requestId: string, scope?: ReferralRequestScope) => (await get<{ request: RecordsRequest }>(`${API_URL}/records/requests/${encodeURIComponent(requestId)}`, scopedRead(scope))).request, [get], ); const getHistory = useCallback( async (requestId: string, scope?: ReferralRequestScope, page?: RecordsPageOptions) => get( `${API_URL}/records/requests/${encodeURIComponent(requestId)}/history${cursorParam(page, '?')}`, scopedRead(scope), ), [get], ); /** The caller keeps operationId and sends the same one again on a retry, so only one request is made. */ const createRequest = useCallback( async (operationId: string, input: NewRecordsRequest, scope?: ReferralRequestScope) => post(`${API_URL}/records/requests`, { operationId, ...input }, scopedRead(scope)), [post], ); const writeRequest = useCallback( async (requestId: string, write: { operationId: string; expectedVersion: number; action: RecordsRequestAction } & RecordsDetailsChange, scope?: ReferralRequestScope) => post(`${API_URL}/records/requests/${encodeURIComponent(requestId)}/write`, write, scopedRead(scope)), [post], ); // Every page of a request's originals, at most ten, each certified at the version the first was. A failed, // moved, repeated or unfinished walk keeps what it read and is not complete; it proves nothing is missing. const listIntakes = useCallback( async (requestId: string, scope?: ReferralRequestScope): Promise => { type Page = { intakes: RecordsIntake[]; requestVersion?: number; next?: string }; const base = `${API_URL}/records/requests/${encodeURIComponent(requestId)}/intakes`; const first = await get(base, scopedRead(scope)); const intakes = [...first.intakes]; const version = first.requestVersion; let certified = typeof version === 'number' && Number.isSafeInteger(version); let next = first.next; const seen = new Set(); for (let pages = 1; certified && next; pages += 1) { if (seen.has(next) || pages >= INTAKE_PAGES) { certified = false; break; } seen.add(next); let page: Page; try { page = await get(base + '?next=' + encodeURIComponent(next), scopedRead(scope)); } catch (failure) { if (scope?.signal.aborted) throw failure; certified = false; break; } intakes.push(...page.intakes); if (page.requestVersion !== version) { certified = false; break; } next = page.next; } const complete = certified && !next; return { intakes, complete, ...(complete ? { requestVersion: version } : {}) }; }, [get], ); /** Finishes recording one owed step by name; the server keeps the original step's actor, time and edition. */ const settleEvidence = useCallback( async (requestId: string, owed: { step: string; operationId: string }, scope?: ReferralRequestScope) => post<{ request: RecordsRequest; evidence: 'settled' | 'owed' | 'unavailable'; message?: string }>( `${API_URL}/records/requests/${encodeURIComponent(requestId)}/write`, { action: 'settle-evidence', step: owed.step, operationId: owed.operationId }, scopedRead(scope), ), [post], ); // Records staff find a patient through the request they are matching, never the chart search their role may // not reach. The server answers identity alone, among this practice's patients; a run of digits is a phone. const lookUpPatients = useCallback( async (requestId: string, term: string, scope?: ReferralRequestScope): Promise => { const trimmed = term.trim(); const key = /^[\d\s()+.-]+$/.test(trimmed) ? 'phone' : 'name'; const found = await get<{ patients: RecordsPatientCandidate[] }>( `${API_URL}/records/requests/${encodeURIComponent(requestId)}/patients?${key}=${encodeURIComponent(trimmed)}`, scopedRead(scope), ); return found.patients.map(asPatient); }, [get], ); // Who the request is matched to, read from the request under its practice so the panel can name them. const readMatchedPatient = useCallback( async (requestId: string, scope?: ReferralRequestScope): Promise => { const found = await get<{ patients: RecordsPatientCandidate[] }>( `${API_URL}/records/requests/${encodeURIComponent(requestId)}/patients?matched=true`, scopedRead(scope), ); return found.patients[0] ? asPatient(found.patients[0]) : undefined; }, [get], ); const originalLink = useCallback( async (requestId: string, intakeId: string, scope?: ReferralRequestScope) => get<{ url: string; expiresIn: number }>( `${API_URL}/records/requests/${encodeURIComponent(requestId)}/intakes/${encodeURIComponent(intakeId)}/original`, scopedRead(scope), ), [get], ); const listImports = useCallback( async (requestId: string, scope?: RecordsCallScope) => (await get<{ imports: RecordsImport[] }>(`${API_URL}/records/requests/${encodeURIComponent(requestId)}/imports`, scoped(scope))).imports, [get], ); /** Opens the reading of one original, or returns the one already open; the same operation id is sent on a retry. */ const startImport = useCallback( async (requestId: string, intakeId: string, operationId: string, scope?: RecordsCallScope) => (await post<{ import: RecordsImport }>( `${API_URL}/records/requests/${encodeURIComponent(requestId)}/intakes/${encodeURIComponent(intakeId)}/imports`, { operationId }, scoped(scope), )).import, [post], ); const getImport = useCallback( async (requestId: string, importId: string, scope?: RecordsCallScope) => get<{ import: RecordsImport; candidates: ImportCandidate[] }>( `${API_URL}/records/requests/${encodeURIComponent(requestId)}/imports/${encodeURIComponent(importId)}`, scoped(scope), ), [get], ); const decideCandidate = useCallback( async (requestId: string, importId: string, candidateId: string, decision: CandidateDecision, scope?: RecordsCallScope) => post<{ candidate: ImportCandidate; outcome: 'applied' | 'replayed' | 'resumed' }>( `${API_URL}/records/requests/${encodeURIComponent(requestId)}/imports/${encodeURIComponent(importId)}/candidates/${encodeURIComponent(candidateId)}`, decision, scoped(scope), ), [post], ); return { listRequests, getRequest, getHistory, createRequest, writeRequest, listIntakes, settleEvidence, originalLink, listImports, startImport, getImport, decideCandidate, lookUpPatients, readMatchedPatient, }; };