import { useMemo } from 'react'; import type { Bundle, Composition, CompositionSection, Patient, Resource } from 'fhir/r4'; import { usePatientServiceContext } from '../../services'; import { useAnchorResults } from '../../hooks/useAnchorResults'; import { SnapshotChart } from '../PatientSnapshot/SnapshotChart'; import { downloadBundleJson } from '../../utils/downloadBundleJson'; import './LegalRecordPrintView.css'; /** Stable empty ref array - `useAnchorResults`'s second arg is only meaningful for B2's live * "some pinned versions couldn't be retrieved" case. A B3 document Bundle carries no such case: * every `Composition.section[].entry[].reference` is guaranteed (by `buildLegalExportDocument`'s * own contract) to resolve against a `Bundle.entry.fullUrl`, so both section mappings always pass * an empty unresolved list. */ const NO_REFS: string[] = []; interface LegalRecordPrintViewProps { /** The signed FHIR document Bundle returned by `exportCareViewAnchor` (Task 8). */ bundle: Bundle; } /** Format an ISO instant for display, falling back to the raw value / a neutral phrase. */ function formatInstant(iso?: string): string { if (!iso) return 'Unknown'; const d = new Date(iso); return Number.isNaN(d.getTime()) ? iso : d.toLocaleString(); } /** Strip HTML tags for a plain-text fallback rendering of a `Narrative.div`. */ function stripTags(html: string): string { return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); } /** * Best-effort parse of the anchor hash + capture time embedded in the anchor section's narrative * text. `legal-export-document.ts`'s `anchorSectionNarrative()` (infra) is the ONLY place these * values are written into the returned Bundle - there is no separate structured field for either * one - so this regex is coupled to that exact wording ("... captured at {time}. Anchor hash: * {hash}." / "Anchor hash not available."). If the wording ever changes this degrades to * `undefined` (never throws); the raw narrative text is always rendered alongside as the ground * truth so nothing is lost even if the parse misses. */ export function parseAnchorNarrative(divHtml: string | undefined): { hash?: string; capturedAt?: string } { if (!divHtml) return {}; const hashMatch = divHtml.match(/Anchor hash:\s*([^.<]+)\./); const capturedMatch = divHtml.match(/captured at\s*([^<]+?)\.\s*Anchor hash/); return { hash: hashMatch?.[1]?.trim(), capturedAt: capturedMatch?.[1]?.trim(), }; } /** Resolve a Composition section's `entry[].reference`s against the Bundle's own `fullUrl`s. */ function resolveSectionResources( section: CompositionSection | undefined, byFullUrl: Map, ): Resource[] { if (!section?.entry) return []; const resources: Resource[] = []; for (const ref of section.entry) { const resource = ref.reference ? byFullUrl.get(ref.reference) : undefined; if (resource) resources.push(resource); } return resources; } /** Format a Patient's primary HumanName without pulling in the `@wordpress/components`-backed * `FhirHumanNameDisplay` (this file stays zero-WordPress, per the plugin's decoupling rule). */ function formatPatientName(patient: Patient): string { const name = patient.name?.find((n) => n.use === 'official') ?? patient.name?.[0]; if (!name) return 'Unknown patient'; if (name.text) return name.text; const parts = [name.prefix?.join(' '), name.given?.join(' '), name.family, name.suffix?.join(' ')].filter( Boolean, ); return parts.join(' ') || 'Unknown patient'; } /** * Human-readable, printable copy of a B3 signed legal-export document Bundle. Purely * presentational (like `SnapshotChart` itself) - takes only the already-fetched Bundle, resolves * each `Composition.section[].entry[].reference` against the Bundle's own `entry[].fullUrl`s, maps * each section's resources to `SnapshotTypeResult[]` by REUSING B2's `useAnchorResults` (not a * fork), and renders one `` per Composition section under its own title. * * Patient name is read from `usePatientServiceContext()` (same pattern as `AnchorListSection` / * `PatientNotesSection` in App.tsx) since this view is always opened from within a patient-scoped * route - it is NOT sourced from the Bundle, which carries only `Composition.subject.reference` * (`Patient/{pid}`, no HumanName). Falls back to that raw reference if patient context is * unavailable for any reason. * * Everything in the integrity block, by contrast, comes ONLY from the returned Bundle - no field * is invented. "Anchor hash verified" is not a boolean read off the Bundle (no such field exists * on the signed document); it is an architectural invariant: `POST.../export` returns 409 * `anchor-tampered` and never signs/returns a document at all when the anchor's hash fails, so a * Bundle reaching this component could only exist if that check already passed. * * Zero create/edit/write affordances - this is the print/export surface, not a chart view. */ export function LegalRecordPrintView({ bundle }: LegalRecordPrintViewProps) { const { patientState } = usePatientServiceContext(); const composition = useMemo(() => { const first = bundle.entry?.[0]?.resource; return first?.resourceType === 'Composition' ? (first as Composition) : undefined; }, [bundle]); const byFullUrl = useMemo(() => { const map = new Map(); for (const entry of bundle.entry ?? []) { if (entry.fullUrl && entry.resource) map.set(entry.fullUrl, entry.resource as Resource); } return map; }, [bundle]); const anchorSection = composition?.section?.[0]; const pitSection = composition?.section?.[1]; const anchorResources = useMemo( () => resolveSectionResources(anchorSection, byFullUrl), [anchorSection, byFullUrl], ); const pitResources = useMemo( () => resolveSectionResources(pitSection, byFullUrl), [pitSection, byFullUrl], ); // Both calls happen unconditionally, every render, in fixed order - rules-of-hooks - even // though `pitResources` is often empty (no PIT section requested for this export). const anchorResults = useAnchorResults(anchorResources, NO_REFS); const pitResults = useAnchorResults(pitResources, NO_REFS); if (!composition) { return (

This does not look like a valid legal export document (no Composition at entry[0]).

); } const { hash: anchorHash, capturedAt } = parseAnchorNarrative(anchorSection?.text?.div); const anchorNarrativeText = anchorSection?.text?.div ? stripTags(anchorSection.text.div) : ''; const pitNarrativeText = pitSection?.text?.div ? stripTags(pitSection.text.div) : ''; const patientRef = composition.subject?.reference; const patientLabel = patientState ? formatPatientName(patientState) : patientRef || 'Unknown patient'; const generatedAt = composition.date ?? bundle.timestamp; const authorRefs = (composition.author ?? []) .map((a) => a.reference) .filter((r): r is string => Boolean(r)); const signatureTypeLabel = bundle.signature?.type?.[0]?.display ?? bundle.signature?.type?.[0]?.code; return (

{composition.title ?? 'Care-View Legal Record'}

Patient
{patientLabel}
Encounter
{composition.encounter?.reference || 'Not documented'}
Generated
{formatInstant(generatedAt)}
Author
{authorRefs.length ? authorRefs.join(', ') : 'Not documented'}

Integrity

Anchor hash verified ✓

Anchor hash
{anchorHash || 'Not stated in the exported narrative'}
Captured
{capturedAt ? formatInstant(capturedAt) : 'Not stated in the exported narrative'}
Signed
{bundle.signature ? formatInstant(bundle.signature.when) : 'No signature present'}
Signing authority
{bundle.signature?.who?.reference || 'Not documented'}
{signatureTypeLabel && (
Signature type
{signatureTypeLabel}
)}
{anchorNarrativeText && (

{anchorNarrativeText}

)}

Verify independently per docs/audit-and-compliance/legal-export-verification.md.

The downloaded JSON Bundle (including its cryptographic signature) is the authoritative legal record. This printed page is a convenience copy for review only.

{anchorSection?.title ?? 'What was seen at the encounter'}

{/* Note: the anchor's own narrative (hash + capture time + unresolved-ref count) is rendered once, above, in the Integrity block — not repeated here. */} {/* `SnapshotChart` renders nothing at all (not even an empty-state line) when its `results` array is entirely empty — guard explicitly so an empty section is never a silent blank in a legal document. */} {anchorResults.length === 0 ? (

Nothing recorded in "{anchorSection?.title ?? 'What was seen at the encounter'}".

) : ( )}
{pitSection && (

{pitSection.title}

{/* Always shown: carries both the "weaker, timestamp-based claim" disclaimer and — if the server-side reconstruction truncated any resource types — the honest truncation note. This is the ONLY place that truncation surfaces: it's recorded per-TYPE (`pit.truncatedTypes`) server-side, a different shape than the anchor's per-REFERENCE `unresolvedRefs`, so it can't be folded into `useAnchorResults`'s per-row "couldn't be retrieved" mechanism without inventing fake reference ids. */} {pitNarrativeText && (

{pitNarrativeText}

)} {pitResults.length === 0 ? (

Nothing recorded in "{pitSection.title}".

) : ( )}
)}
); } export default LegalRecordPrintView;