/** * Frontend handoff for the EMA ePI Bundle lookup. * * Browser CORS was not enabled on the live EMA UAT endpoint on 2026-08-06. * Pass a same-origin backend-for-frontend (BFF) endpoint here. The BFF should * forward the EMA FHIR searchset Bundle unchanged. */ export const EMA_DOCUMENT_TYPE_SYSTEM = "http://ema.europa.eu/fhir/CodeSystem/100000155531"; export const PACKAGE_LEAFLET_CODE = "100000155538"; export const EMA_GTIN_EXTENSION = "http://ema.europa.eu/fhir/StructureDefinition/ext-epi-gtin"; export interface FhirResource { resourceType: string; id?: string; [element: string]: unknown; } export interface FhirBundleEntry { fullUrl?: string; resource?: FhirResource; search?: { mode?: string }; } export interface FhirBundle extends FhirResource { resourceType: "Bundle"; type: string; total?: number; link?: Array<{ relation: string; url: string }>; entry?: FhirBundleEntry[]; } export interface FhirCoding { system?: string; code?: string; display?: string; } export interface FhirExtension { url: string; valueIdentifier?: { system?: string; value?: string }; [element: string]: unknown; } export interface CompositionSection { title?: string; text?: { status?: string; div?: string }; section?: CompositionSection[]; [element: string]: unknown; } export interface FhirComposition extends FhirResource { resourceType: "Composition"; language?: string; status?: string; date?: string; title?: string; type?: { coding?: FhirCoding[] }; extension?: FhirExtension[]; section?: CompositionSection[]; } export interface LeafletCandidate { /** The exact FHIR document Bundle returned inside the searchset entry. */ bundle: FhirBundle; composition: FhirComposition; deprecated: boolean; } export interface LeafletLookupResult { /** The exact outer Bundle returned by the BFF/EMA search. */ searchset: FhirBundle; allDocumentBundles: FhirBundle[]; leafletCandidates: LeafletCandidate[]; nonDeprecatedLeaflets: LeafletCandidate[]; deprecatedLeaflets: LeafletCandidate[]; } export interface LeafletRequest { /** Required same-origin BFF URL, for example /api/ema/fhir/Bundle. */ endpoint: string; gtin: string; language: string; signal?: AbortSignal; fetchImplementation?: typeof fetch; } export class LeafletLookupError extends Error { constructor( message: string, readonly status?: number, readonly payload?: unknown, ) { super(message); this.name = "LeafletLookupError"; } } function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } export function isFhirBundle(value: unknown): value is FhirBundle { return ( isRecord(value) && value.resourceType === "Bundle" && typeof value.type === "string" && (value.entry === undefined || Array.isArray(value.entry)) ); } function isComposition(value: unknown): value is FhirComposition { return isRecord(value) && value.resourceType === "Composition"; } function compositionsIn(bundle: FhirBundle): FhirComposition[] { return (bundle.entry ?? []) .map((entry) => entry.resource) .filter(isComposition); } function isPackageLeaflet(composition: FhirComposition): boolean { return (composition.type?.coding ?? []).some( (coding) => coding.system === EMA_DOCUMENT_TYPE_SYSTEM && coding.code === PACKAGE_LEAFLET_CODE, ); } function hasRequestedGtin(composition: FhirComposition, gtin: string): boolean { return (composition.extension ?? []).some( (extension) => extension.url === EMA_GTIN_EXTENSION && extension.valueIdentifier?.value === gtin, ); } export function extractLeafletCandidates( searchset: FhirBundle, gtin: string, language: string, ): LeafletLookupResult { if (searchset.type !== "searchset") { throw new LeafletLookupError(`Expected Bundle.type=searchset, received ${searchset.type}`); } const allDocumentBundles = (searchset.entry ?? []) .map((entry) => entry.resource) .filter(isFhirBundle) .filter((bundle) => bundle.type === "document"); const leafletCandidates = allDocumentBundles.flatMap((bundle) => compositionsIn(bundle) .filter( (composition) => composition.language === language && isPackageLeaflet(composition) && hasRequestedGtin(composition, gtin), ) .map((composition) => ({ bundle, composition, deprecated: composition.status === "deprecated", })), ); return { searchset, allDocumentBundles, leafletCandidates, nonDeprecatedLeaflets: leafletCandidates.filter((candidate) => !candidate.deprecated), deprecatedLeaflets: leafletCandidates.filter((candidate) => candidate.deprecated), }; } export async function requestLeafletBundles({ endpoint, gtin, language, signal, fetchImplementation = fetch, }: LeafletRequest): Promise { const query = new URLSearchParams({ carrierValue: gtin, language }); const separator = endpoint.includes("?") ? "&" : "?"; const response = await fetchImplementation(`${endpoint}${separator}${query}`, { method: "GET", headers: { accept: "application/fhir+json" }, signal, }); const text = await response.text(); let payload: unknown; try { payload = text ? JSON.parse(text) : null; } catch { throw new LeafletLookupError( `Leaflet lookup returned non-JSON content (HTTP ${response.status})`, response.status, text, ); } if (!response.ok) { throw new LeafletLookupError( `Leaflet lookup failed with HTTP ${response.status}`, response.status, payload, ); } if (!isFhirBundle(payload)) { throw new LeafletLookupError("Leaflet lookup did not return a FHIR Bundle", response.status, payload); } return extractLeafletCandidates(payload, gtin, language); } /** * Returns the exact document Bundle only when selection is unambiguous. * `preliminary` is non-deprecated, but it must not be presented as regulatory approval. */ export function selectSingleNonDeprecatedLeaflet(result: LeafletLookupResult): FhirBundle { if (result.nonDeprecatedLeaflets.length === 0) { throw new LeafletLookupError("No non-deprecated Package Leaflet matched the GTIN and language"); } if (result.nonDeprecatedLeaflets.length > 1) { throw new LeafletLookupError( `Ambiguous response: ${result.nonDeprecatedLeaflets.length} non-deprecated Package Leaflets matched`, ); } return result.nonDeprecatedLeaflets[0].bundle; } /** Collects all FHIR narrative XHTML fragments, including nested sections. */ export function collectNarrativeXhtml(composition: FhirComposition): string[] { const fragments: string[] = []; const visit = (sections: CompositionSection[] = []) => { for (const section of sections) { if (section.text?.div) fragments.push(section.text.div); visit(section.section); } }; visit(composition.section); return fragments; } // Example: // const result = await requestLeafletBundles({ // endpoint: "/api/ema/fhir/Bundle", // proposed same-origin BFF route // gtin: "22998763729112", // language: "es", // }); // const exactFhirDocumentBundle = selectSingleNonDeprecatedLeaflet(result); // Render Composition.section[].text.div only through an approved HTML sanitizer.