import { Button } from "../../../components/ui/button";
import "./PersonalImports.css";
import { useCallback, useEffect, useRef, useState, type SetStateAction } from 'react';
import { useAuthServiceContext } from '../../../services';
import { authSessionId } from '../../../services/authStorage';
import { environment } from '../../../environments/environment';
import { HttpError } from '../../../services/HttpService';
import { usePersonalRecordsService, personalOperationId, type PersonalImport, type PersonalEventEvidence, type PersonalConnection, type PersonalCandidates, type PersonalAcceptance, type PersonalAcceptanceRequest, type PersonalImportRequest, type PersonalIdentityStop } from '../../../services/PersonalRecordsService';
import { grantedTypes, type RetrievableType } from '../../../services/RecordsConnectionsService';
import { useSaveScope, isSaveRetired, type SaveOperation } from '../../../utils/saveOperation';
import { relayServerFailure } from '../../../utils/fetchFailureMessage';
import type { RecordsCallScope } from '../../../services/RecordsService';
const callScope = (life: SaveOperation): RecordsCallScope => ({ admit: life.admit, signal: life.transport.signal, organizationId: '' });
const words = (value: unknown) => JSON.stringify(value, null, 2) ?? 'Unconfirmed';
const EventDelivery = ({ evidence }: { evidence?: PersonalEventEvidence }) => !evidence ? null :
{'events' in evidence ? <>
{evidence.events.map((event, index) =>
{event.step.replace(/-/g, ' ')}: {event.state === 'owed'
? 'awaiting handoff' : `handed off via ${event.transport ?? 'unconfirmed destination'}; storage unconfirmed`}
)}
{evidence.more &&
Additional event delivery states have not been returned.
}
> :
Event delivery status is unconfirmed.
}
;
const HOLD_WORDS: Record['code'], string> = {
'identity-conflict': 'This read cannot go ahead: the person in the other system does not match your record here. Check this import operation before starting another.',
'facts-missing': 'This read cannot go ahead: the other system did not return enough about the person to check that it is you. Check this import operation before starting another.',
'reconnect-required': 'This read cannot go ahead until this system is connected again under Your connections. Check this import operation before starting another.',
};
const factsWords = (facts: { birthDate: string; names: string[] }) => (facts.names.join(', ') || 'No name returned') + ' · born ' + (facts.birthDate || 'not returned');
type IdentityCapture = { request: PersonalImportRequest; opening: SaveOperation; stop: PersonalIdentityStop };
const PersonalImports = () => {
const { currentUser, currentOrganization } = useAuthServiceContext();
const api = usePersonalRecordsService();
const owner = [currentUser?.fhir?.resourceType, currentUser?.fhir?.id, currentOrganization?.id, authSessionId(), environment.sessionPortal, window.location.origin].join('|');
const [imports, setImports] = useState([]);
const [connections, setConnections] = useState([]);
const [historyCursor, setHistoryCursor] = useState();
const [connectionCursor, setConnectionCursor] = useState();
const [connectionId, setConnectionId] = useState('');
const [types, setTypes] = useState([]);
const [supersedes, setSupersedes] = useState();
const [pendingImport, showPendingImport] = useState();
const pendingImportNow = useRef();
const setPendingImport = useCallback((update: SetStateAction) => {
const next = typeof update === 'function' ? update(pendingImportNow.current) : update;
pendingImportNow.current = next; showPendingImport(next);
}, []);
const [pendingAcceptance, showPendingAcceptance] = useState<{ operationId: string; acceptanceId: string }>();
const pendingAcceptanceNow = useRef<{ operationId: string; acceptanceId: string }>();
const setPendingAcceptance = useCallback((update: SetStateAction<{ operationId: string; acceptanceId: string } | undefined>) => {
const next = typeof update === 'function' ? update(pendingAcceptanceNow.current) : update;
pendingAcceptanceNow.current = next; showPendingAcceptance(next);
}, []);
const [identity, showIdentity] = useState();
const identityNow = useRef();
const setIdentity = useCallback((next: IdentityCapture | undefined) => { identityNow.current = next; showIdentity(next); }, []);
const [review, setReview] = useState();
const reviewNow = useRef(review); reviewNow.current = review;
const [decisions, setDecisions] = useState>({});
const [reviewed, setReviewed] = useState(false);
const [acceptances, setAcceptances] = useState([]);
const [acceptanceCursor, setAcceptanceCursor] = useState();
const [original, setOriginal] = useState<{ operationId: string; body: unknown }>();
const [busy, setBusy] = useState();
const [error, setError] = useState();
const [notice, setNotice] = useState();
const serial = useRef(0);
const clear = useCallback(() => {
serial.current += 1;
setImports([]); setConnections([]); setHistoryCursor(undefined); setConnectionCursor(undefined);
setConnectionId(''); setTypes([]); setSupersedes(undefined); setPendingImport(undefined); setPendingAcceptance(undefined); setIdentity(undefined);
setReview(undefined); setDecisions({}); setReviewed(false); setAcceptances([]); setAcceptanceCursor(undefined);
setOriginal(undefined); setBusy(undefined); setError(undefined); setNotice(undefined);
}, [setPendingImport, setPendingAcceptance, setIdentity]);
const lives = useSaveScope(owner, clear);
useEffect(clear, [owner, clear]);
const perform = useCallback(async (label: string, work: (life: SaveOperation) => Promise) => {
const mine = ++serial.current;
const life = lives.start();
setBusy(label); setError(undefined);
try { await work(life); }
catch (failure) { if (!isSaveRetired(failure) && life.belongs() && mine === serial.current) setError(relayServerFailure(failure, 'This step is unconfirmed. Check its stored status before doing anything again.')); }
finally { if (life.belongs() && mine === serial.current) setBusy(undefined); }
}, [lives]);
const install = (next: PersonalImport) => setImports(now => {
const previous = now.find(row => row.operationId === next.operationId);
if (previous && previous.rowVersion > next.rowVersion) return now;
return [next, ...now.filter(row => row.operationId !== next.operationId)];
});
const history = useCallback((cursor?: string) => perform('Loading import history', async life => {
const page = await life.step(() => api.imports(callScope(life), cursor));
if (!Array.isArray(page.imports)) throw new Error('Import history is unavailable');
setImports(now => {
const byId = new Map((cursor ? now : []).map(row => [row.operationId, row]));
for (const row of page.imports) {
const previous = now.find(old => old.operationId === row.operationId);
byId.set(row.operationId, previous && previous.rowVersion > row.rowVersion ? previous : row);
}
return [...byId.values()];
});
setHistoryCursor(page.cursor);
}), [api, perform]);
useEffect(() => {
void history();
const life = lives.start();
void life.step(() => api.connections(callScope(life))).then(page => {
if (life.belongs()) { setConnections(page.connections); setConnectionCursor(page.cursor); }
}).catch(failure => { if (!isSaveRetired(failure) && life.belongs()) setError('Connections for import could not be loaded.'); });
return () => life.retire();
}, [api, history, lives, owner]);
const inspectImport = (operationId: string) => perform('Checking import', async life => {
const row = await life.step(() => api.importStatus(operationId, callScope(life))); install(row);
if (['attached', 'saved', 'chart-pending', 'refused', 'superseded'].includes(row.state)) setPendingImport(now => now === operationId ? undefined : now);
setNotice('Stored import status: ' + row.state + '. This check does not read the other system.');
});
const observeRead = async (life: SaveOperation, operationId: string) => {
const row = await life.step(() => api.importStatus(operationId, callScope(life))); install(row);
const ownsPending = pendingImportNow.current === operationId;
if (['attached', 'saved', 'chart-pending', 'refused', 'superseded'].includes(row.state)) setPendingImport(now => now === operationId ? undefined : now);
if (ownsPending) setSupersedes(undefined);
setNotice('Read outcome observed. Retaining an original does not confirm chart entries or evidence delivery.');
};
const holdIdentity = (capture: IdentityCapture) => {
if (!capture.opening.belongs() || pendingImportNow.current !== capture.request.operationId) return;
setIdentity(capture);
};
const start = () => {
if (!connectionId || !types.length || pendingImport) return;
const operationId = personalOperationId();
const request: PersonalImportRequest = { operationId, connectionId, types: [...types], ...(supersedes ? { supersedes: { operationId: supersedes.operationId, expectedVersion: supersedes.rowVersion } } : {}) };
setPendingImport(operationId); setIdentity(undefined);
void perform('Reading selected records', async life => {
const answer = await life.step(() => api.startImport(request, callScope(life)), true);
if (answer.kind !== 'started') return holdIdentity({ request, opening: life, stop: answer });
await observeRead(life, operationId);
});
};
const confirmIdentity = () => {
const capture = identityNow.current;
if (!capture || capture.stop.kind !== 'review' || !capture.opening.belongs() || pendingImportNow.current !== capture.request.operationId) return;
const { review: shown, code } = capture.stop;
const link = { evidenceDigest: shown.evidenceDigest, confirm: code === 'link-required' ? 'self' : 'self-name-reviewed' } as const;
void perform('Confirming these records are yours', async life => {
const answer = await life.step(() => api.startImport({ ...capture.request, link }, callScope(life)), true);
if (identityNow.current !== capture || !capture.opening.belongs()) return;
if (answer.kind !== 'started') {
if (answer.requestDigest !== capture.stop.requestDigest) throw new Error('The identity answer no longer matches this read');
return holdIdentity({ ...capture, stop: answer });
}
setIdentity(undefined);
await observeRead(life, capture.request.operationId);
});
};
const loadReview = (operationId: string, cursor?: string) => perform('Loading chart review', async life => {
const [page, previous] = await life.step(() => Promise.all([api.candidates(operationId, callScope(life), cursor), api.acceptances(operationId, callScope(life))]));
setReview(page); setDecisions({}); setReviewed(false); setOriginal(undefined);
setAcceptances(previous.acceptances); setAcceptanceCursor(previous.cursor);
});
const choose = (key: string, decision: string) => setDecisions(now => {
const { [key]: _previous, ...rest } = now;
return decision === 'accept' || decision === 'decline' ? { ...rest, [key]: decision } : rest;
});
const settlePendingAcceptance = (stored: PersonalAcceptance) => {
const pending = pendingAcceptanceNow.current;
if (!pending || pending.operationId !== stored.operationId || pending.acceptanceId !== stored.acceptanceId
|| stored.items.some(item => item.phase === 'write-claimed')) return false;
setPendingAcceptance(now => now?.operationId === stored.operationId && now.acceptanceId === stored.acceptanceId ? undefined : now);
return true;
};
const accept = () => {
if (!review || !reviewed || pendingAcceptance) return;
const items = review.candidates.flatMap(item => {
const decision = decisions[item.sourceIdentity + '#' + item.sourceEdition];
return decision ? [{ sourceIdentity: item.sourceIdentity, sourceEdition: item.sourceEdition, proposalDigest: item.proposalDigest, decision,
expectedLocalCopies: item.priorCopies.map(copy => ({ resource: copy.resource, version: copy.version })) }] : [];
});
if (!items.length) return;
const acceptanceId = personalOperationId(), operationId = review.operationId;
const body: PersonalAcceptanceRequest = { acceptanceId, expectedImportVersion: review.importVersion, originalVersionId: review.originalVersionId, originalSha256: review.originalSha256, items };
setPendingAcceptance({ operationId, acceptanceId });
void perform('Recording chart decisions', async life => {
try { await life.step(() => api.accept(operationId, body, callScope(life)), true); }
catch (failure) {
if (failure instanceof HttpError && [400, 403, 404, 409].includes(failure.status)) setPendingAcceptance(now => now?.operationId === operationId && now.acceptanceId === acceptanceId ? undefined : now);
throw failure;
}
const stored = await life.step(() => api.acceptanceStatus(operationId, acceptanceId, callScope(life)));
setAcceptances(now => [stored, ...now.filter(row => row.acceptanceId !== stored.acceptanceId)]);
if (settlePendingAcceptance(stored) && reviewNow.current === review) { setDecisions({}); setReviewed(false); }
setNotice('Stored decisions observed. Each item and its evidence handoff have their own outcome below.');
});
};
const checkAcceptance = (operationId: string, acceptanceId: string) => perform('Checking chart decisions', async life => {
const stored = await life.step(() => api.acceptanceStatus(operationId, acceptanceId, callScope(life)));
setAcceptances(now => [stored, ...now.filter(row => row.acceptanceId !== stored.acceptanceId)]);
settlePendingAcceptance(stored);
});
const readOriginal = (operationId: string) => perform('Reading retained original', async life => {
const body = await life.step(() => api.original(operationId, callScope(life))); setOriginal({ operationId, body });
});
const selected = connections.find(row => row.connectionId === connectionId);
const offeredTypes = selected ? grantedTypes(selected.grantedScopes) : [];
return
Read your records from a connected system
Choose what to read, then review the retained original before adding chart entries. Importing medication requests records what the other system said; it does not prescribe, send an order, or settle diagnostic work.
{error && {error}
}{notice && {notice}
}{busy && {busy}…
}
Connection to read { setConnectionId(event.target.value); setTypes([]); setSupersedes(undefined); }}>
Choose a connection {connections.map(row => {row.vendorDisplay} · {row.environmentLabel} )}
{connectionCursor && void perform('Loading more connections', async life => {
const page = await life.step(() => api.connections(callScope(life), connectionCursor));
setConnections(now => [...now, ...page.connections.filter(row => !now.some(old => old.connectionId === row.connectionId))]); setConnectionCursor(page.cursor);
})}>More connections for import }
Record types to read {offeredTypes.map(type =>
setTypes(now => event.target.checked ? [...now, type] : now.filter(value => value !== type))} />{type}
)}
{supersedes && This new read explicitly supersedes {supersedes.operationId} at version {supersedes.rowVersion}. Its original and history remain retained.
}
Read selected records
{identity?.stop.kind === 'review' && identity.request.operationId === pendingImport &&
Confirm these records are yours
The other system names this person: {factsWords(identity.stop.review.source)}
Your record here: {factsWords(identity.stop.review.local)}
{identity.stop.code === 'name-review' && The names differ. Confirm only if both are you.
}
Yes, these records are mine
}
{identity?.stop.kind === 'hold' && identity.request.operationId === pendingImport && {HOLD_WORDS[identity.stop.code]}
}
{pendingImport && Import operation {pendingImport} is not yet settled here. void inspectImport(pendingImport)}>Check import operation
}
Import history
void history()}>Refresh import history
{imports.length === 0 && !busy && No import history has been returned.
}
{imports.map(row =>
{row.operationId} — {row.state}, version {row.rowVersion}
{row.actor} · {row.at}
Original: {row.original.status}{row.original.sha256 ? ' · SHA256 ' + row.original.sha256 : ''}
Evidence handoff: {words(row.evidence)}. Handoff is not confirmation that evidence has been stored.
{row.supersededBy && Superseded by {row.supersededBy}; this history is retained.
}
{row.summary && Read summary {words(row.summary)} }
void inspectImport(row.operationId)}>Check stored import
{row.original.status === 'attached' && <>
void readOriginal(row.operationId)}>View retained original
void loadReview(row.operationId)}>Review chart candidates
>}
void perform('Resuming local original proof', async life => {
await life.step(() => api.resumeImport(row.operationId, row.rowVersion, callScope(life)), true);
const next = await life.step(() => api.importStatus(row.operationId, callScope(life))); install(next);
setNotice('Local proof checked. No vendor read was repeated.');
})}>Resume local original proof only
{!row.supersededBy && row.rowVersion > 0 && row.original.status === 'unconfirmed' && ['reading', 'read-unconfirmed', 'original-pending', 'original-unconfirmed'].includes(row.state) && { setSupersedes(row); setConnectionId(row.connectionId); setTypes([]); setPendingImport(now => now === row.operationId ? undefined : now); }}>Prepare an explicit superseding read }
)}
{historyCursor && void history(historyCursor)}>More import history }
{original && Retained original for {original.operationId} {words(original.body)} }
{review && Review chart candidates
Original version {review.originalVersionId} · SHA256 {review.originalSha256} · import version {review.importVersion} · mapping {review.mapping}
void readOriginal(review.operationId)}>Read the reviewed original
{review.candidates.map(item =>
{item.kind}: {item.sourceIdentity} · edition {item.sourceEdition}
{words(item.fields)}
{item.unavailable && Held: {item.unavailable}. The original remains available.
}
Existing local copies reviewed: {item.priorCopies.length ? item.priorCopies.map(copy => copy.resource + ' version ' + copy.version + (copy.sameEdition ? ' (same source edition)' : '')).join(', ') : 'none returned'}
Decision choose(item.sourceIdentity + '#' + item.sourceEdition, event.target.value)}>
Leave undecided copy.sameEdition)}>Accept into chart Decline this item
)}
Items not offered for chart entry {words(review.skipped)}
setReviewed(event.target.checked)} />I reviewed the retained original, its source status and authors, and all existing local copies shown.
Record selected decisions
{review.cursor && void loadReview(review.operationId, review.cursor)}>More chart candidates }
}
{pendingAcceptance && void checkAcceptance(pendingAcceptance.operationId, pendingAcceptance.acceptanceId)}>Check acceptance operation }
{review && Chart decision history {acceptanceCursor && void perform('Loading decision history', async life => {
const page = await life.step(() => api.acceptances(review.operationId, callScope(life), acceptanceCursor));
setAcceptances(now => [...now, ...page.acceptances.filter(row => !now.some(old => old.acceptanceId === row.acceptanceId))]); setAcceptanceCursor(page.cursor);
})}>More chart decision history } }
;
};
export default PersonalImports;