import type { FastifyBaseLogger } from "fastify"; import type { PackageType } from "../utils/package-profiles.js"; const EMA_MEDICINES_URL = "https://www.ema.europa.eu/en/documents/report/medicines-output-medicines_json-report_en.json"; const RXNORM_BASE_URL = "https://rxnav.nlm.nih.gov/REST"; const OPENFDA_NDC_URL = "https://api.fda.gov/drug/ndc.json"; const EMA_REFRESH_INTERVAL_MS = 12 * 60 * 60 * 1000; const EMA_FETCH_TIMEOUT_MS = 15_000; const RXNORM_FETCH_TIMEOUT_MS = 8_000; const OPENFDA_FETCH_TIMEOUT_MS = 8_000; export const MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT = 6; export const MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT = 20; const RXNORM_SEARCH_LIMIT = MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT; const OPENFDA_SEARCH_LIMIT = MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT; const GERMAN_INGREDIENT_NORMALIZATION: Record = { acetylsalicylsaeure: "acetylsalicylic acid", acetylsalicylsaure: "acetylsalicylic acid", aspirin: "acetylsalicylic acid", colecalciferol: "cholecalciferol", dimethylfumarat: "dimethyl fumarate", paracetamol: "acetaminophen", }; export type MedicationEnrichmentSearchSource = "ema" | "rxnorm" | "openfda"; export type MedicationEnrichmentCombinedSource = | MedicationEnrichmentSearchSource | "ema+rxnorm" | "ema+openfda" | "rxnorm+openfda" | "ema+rxnorm+openfda"; export type MedicationEnrichmentSearchResult = { code: string; name: string; genericName: string | null; authorisationHolder: string | null; therapeuticArea: string | null; matchType: "brand" | "ingredient"; genericStatus: "generic" | "original" | "unknown"; authorisationDate: string | null; source: MedicationEnrichmentSearchSource; packageOptions: MedicationEnrichmentPackageOption[]; }; export type MedicationEnrichmentStrengthOption = { label: string; pillWeightMg: number | null; doseUnit: "mg" | "g" | "mcg" | "ml" | "IU" | "units" | "drops" | "puffs" | null; }; export type MedicationEnrichmentPackageOption = { label: string; description: string; packageType: PackageType; packCount: number; blistersPerPack: number | null; pillsPerBlister: number | null; totalPills: number | null; looseTablets: number | null; packageAmountValue: number | null; packageAmountUnit: "ml" | "g" | null; }; export type MedicationEnrichmentSearchResponse = { query: string; normalizedQuery: string; hasMore: boolean; results: MedicationEnrichmentSearchResult[]; }; export type MedicationEnrichmentEnrichRequest = { query: string; name: string; genericName?: string | null; code?: string | null; source?: MedicationEnrichmentSearchSource | null; }; export type MedicationEnrichmentEnrichResponse = { selection: { name: string; genericName: string | null; therapeuticArea: string | null; indication: string | null; atcCode: string | null; source: MedicationEnrichmentCombinedSource; }; suggestions: { name: string; genericName: string | null; medicationForm: "capsule" | "tablet" | "liquid" | "topical" | null; strengthOptions: MedicationEnrichmentStrengthOption[]; packageOptions: MedicationEnrichmentPackageOption[]; }; meta: { rxNormMatched: boolean; openFdaMatched: boolean; partial: boolean; note: string | null; }; }; type MedicationEnrichmentLogger = Pick; type EmaMedicineRow = Record; type EmaCatalogEntry = { code: string; name: string; genericName: string | null; authorisationHolder: string | null; therapeuticArea: string | null; indication: string | null; atcCode: string | null; genericStatus: "generic" | "original" | "unknown"; authorisationDate: string | null; nameKey: string; genericKey: string; activeSubstanceKey: string; }; type EmaCatalogSnapshot = { entries: EmaCatalogEntry[]; loadedAt: number; }; type RxNormDrugConceptProperty = { rxcui?: string; name?: string; synonym?: string; tty?: string; }; type RxNormDrugConceptGroup = { tty?: string; conceptProperties?: RxNormDrugConceptProperty[]; }; type RxNormDrugsResponse = { drugGroup?: { conceptGroup?: RxNormDrugConceptGroup[]; }; }; type RxNormConceptProperty = { name?: string; }; type RxNormConceptGroup = { conceptProperties?: RxNormConceptProperty[]; }; type RxNormRelatedResponse = { relatedGroup?: { conceptGroup?: RxNormConceptGroup[]; }; }; type RxNormEnrichment = { strengthOptions: MedicationEnrichmentStrengthOption[]; medicationForm: "capsule" | "tablet" | "liquid" | "topical" | null; }; type OpenFdaActiveIngredient = { name?: string; strength?: string; }; type OpenFdaProduct = { product_ndc?: string; product_id?: string; generic_name?: string; brand_name?: string; labeler_name?: string; dosage_form?: string; marketing_start_date?: string; active_ingredients?: OpenFdaActiveIngredient[]; packaging?: OpenFdaPackaging[]; }; type OpenFdaPackaging = { description?: string; package_ndc?: string; }; type OpenFdaResponse = { results?: OpenFdaProduct[]; }; type OpenFdaEnrichment = { name: string; genericName: string | null; strengthOptions: MedicationEnrichmentStrengthOption[]; medicationForm: "capsule" | "tablet" | "liquid" | "topical" | null; packageOptions: MedicationEnrichmentPackageOption[]; }; type ParsedOpenFdaPackagingSegment = { quantity: number; itemText: string; containerCount: number; containerText: string; }; const defaultLogger: MedicationEnrichmentLogger = { debug: (msg: string) => console.debug(msg), info: (msg: string) => console.info(msg), warn: (msg: string) => console.warn(msg), error: (msg: string) => console.error(msg), }; let activeLogger: MedicationEnrichmentLogger = defaultLogger; let emaCatalogSnapshot: EmaCatalogSnapshot | null = null; let emaRefreshPromise: Promise | null = null; let refreshTimer: ReturnType | null = null; let schedulerStarted = false; export class MedicationEnrichmentServiceError extends Error { statusCode: number; code: string; constructor(message: string, statusCode: number, code: string) { super(message); this.name = "MedicationEnrichmentServiceError"; this.statusCode = statusCode; this.code = code; } } function sanitizeText(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value .replace(/<[^>]+>/g, " ") .replace(/ /gi, " ") .replace(/"/gi, '"') .replace(/'/gi, "'") .replace(/'/gi, "'") .replace(/&/gi, "&") .replace(/\s+/g, " ") .trim(); return trimmed.length > 0 ? trimmed : null; } function parseEmaDate(value: unknown): string | null { const sanitized = sanitizeText(value); if (!sanitized) return null; const match = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(sanitized); if (!match) return sanitized; const [, day, month, year] = match; return `${year}-${month}-${day}`; } function parseCompactDate(value: unknown): string | null { const sanitized = sanitizeText(value); if (!sanitized) return null; const match = /^(\d{4})(\d{2})(\d{2})$/.exec(sanitized); if (!match) return null; const [, year, month, day] = match; return `${year}-${month}-${day}`; } function normalizeSearchText(value: string): string { return value .normalize("NFKD") .replace(/[\u0300-\u036f]/g, "") .replace(/[(){}[\],/]/g, " ") .replace(/[-_]+/g, " ") .replace(/\s+/g, " ") .trim() .toLowerCase(); } function removeStrengthHints(value: string): string { return value .replace(/\b\d+(?:[.,]\d+)?\s*(mg|g|mcg|ml|iu|units?|drops?|puffs?)\b/gi, " ") .replace(/\s+/g, " ") .trim(); } function normalizeIngredientTerm(value: string): string { const normalized = normalizeSearchText(value); return GERMAN_INGREDIENT_NORMALIZATION[normalized] ?? normalized; } function getSearchCandidates(query: string): string[] { const normalized = normalizeSearchText(query); const withoutStrength = normalizeSearchText(removeStrengthHints(query)); const ingredient = normalizeIngredientTerm(query); const ingredientWithoutStrength = normalizeIngredientTerm(withoutStrength); return [...new Set([normalized, withoutStrength, ingredient, ingredientWithoutStrength].filter(Boolean))]; } function isHumanMedicine(row: EmaMedicineRow): boolean { const category = sanitizeText(row.category)?.toLowerCase(); return category === "human"; } function isAuthorisedMedicine(row: EmaMedicineRow): boolean { const status = sanitizeText(row.medicine_status)?.toLowerCase() ?? ""; if (!status.includes("authorised")) return false; return !status.includes("withdrawn") && !status.includes("revoked") && !status.includes("refused"); } function deriveGenericStatus(row: EmaMedicineRow): "generic" | "original" | "unknown" { const generic = sanitizeText(row.generic_or_hybrid ?? row.generic)?.toLowerCase(); const biosimilar = sanitizeText(row.biosimilar)?.toLowerCase(); if (generic === "yes" || biosimilar === "yes") return "generic"; if (generic === "no" && biosimilar === "no") return "original"; return "unknown"; } function buildCatalogEntry(row: EmaMedicineRow): EmaCatalogEntry | null { if (!isHumanMedicine(row) || !isAuthorisedMedicine(row)) return null; const name = sanitizeText(row.name_of_medicine); if (!name) return null; const genericName = sanitizeText(row.international_non_proprietary_name_common_name) ?? sanitizeText(row.active_substance) ?? null; const activeSubstance = sanitizeText(row.active_substance); const code = sanitizeText(row.ema_product_number) ?? normalizeSearchText(name); return { code, name, genericName, authorisationHolder: sanitizeText(row.marketing_authorisation_developer_applicant_holder), therapeuticArea: sanitizeText(row.therapeutic_area_mesh), indication: sanitizeText(row.therapeutic_indication), atcCode: sanitizeText(row.atc_code_human), genericStatus: deriveGenericStatus(row), authorisationDate: parseEmaDate(row.marketing_authorisation_date) ?? parseEmaDate(row.european_commission_decision_date), nameKey: normalizeSearchText(name), genericKey: genericName ? normalizeSearchText(genericName) : "", activeSubstanceKey: activeSubstance ? normalizeSearchText(activeSubstance) : "", }; } function extractEmaRows(payload: unknown): EmaMedicineRow[] { if (Array.isArray(payload)) { return payload.filter((item): item is EmaMedicineRow => !!item && typeof item === "object"); } if (!payload || typeof payload !== "object") return []; const record = payload as Record; for (const key of ["medicines", "items", "data", "rows"]) { if (Array.isArray(record[key])) { return record[key].filter((item): item is EmaMedicineRow => !!item && typeof item === "object"); } } return []; } async function fetchJson(url: string, timeoutMs: number): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { try { const response = await fetch(url, { headers: { accept: "application/json" }, redirect: "error", signal: controller.signal, }); if (!response.ok) { throw new Error(`HTTP_${response.status}`); } return await response.json(); } catch (error) { if (error instanceof Error && error.name === "AbortError") { throw new Error(`TIMEOUT_${timeoutMs}MS`); } throw error; } } finally { clearTimeout(timeout); } } async function refreshEmaCatalog(reason: "startup" | "scheduled" | "request"): Promise { if (emaRefreshPromise) return emaRefreshPromise; emaRefreshPromise = (async () => { try { const payload = await fetchJson(EMA_MEDICINES_URL, EMA_FETCH_TIMEOUT_MS); const entries = extractEmaRows(payload) .map(buildCatalogEntry) .filter((entry): entry is EmaCatalogEntry => entry !== null); const snapshot = { entries, loadedAt: Date.now() }; emaCatalogSnapshot = snapshot; activeLogger.info(`[MedicationEnrichment] EMA catalog refreshed (${reason}) with ${entries.length} entries`); return snapshot; } catch (error) { activeLogger.error( `[MedicationEnrichment] EMA catalog refresh failed (${reason}): ${error instanceof Error ? error.message : "unknown"}` ); if (emaCatalogSnapshot) { return emaCatalogSnapshot; } throw new MedicationEnrichmentServiceError( "Medication enrichment is temporarily unavailable.", 503, "MEDICATION_ENRICHMENT_UNAVAILABLE" ); } finally { emaRefreshPromise = null; } })(); return emaRefreshPromise; } async function ensureEmaCatalogReady(): Promise { if (emaCatalogSnapshot) return emaCatalogSnapshot; return refreshEmaCatalog("request"); } function scoreNameAndIngredient( nameKey: string, genericKeys: string[], candidate: string ): { score: number; matchType: "brand" | "ingredient" } | null { const tokens = candidate.split(" ").filter(Boolean); if (tokens.length === 0) return null; const genericKey = genericKeys.join(" "); const ingredientContainsCandidate = genericKeys.some((value) => value === candidate); const ingredientStartsCandidate = genericKeys.some((value) => value.startsWith(candidate)); const ingredientIncludesCandidate = genericKeys.some((value) => value.includes(candidate)); const ingredientTokenMatch = tokens.every((token) => genericKeys.some((value) => value.includes(token))); if (nameKey === candidate) return { score: 120, matchType: "brand" }; if (ingredientContainsCandidate) return { score: 115, matchType: "ingredient" }; if (nameKey.startsWith(candidate)) return { score: 95, matchType: "brand" }; if (ingredientStartsCandidate) return { score: 85, matchType: "ingredient" }; if (nameKey.includes(candidate)) return { score: 70, matchType: "brand" }; if (ingredientIncludesCandidate) return { score: 62, matchType: "ingredient" }; if (tokens.every((token) => nameKey.includes(token))) return { score: 58, matchType: "brand" }; if (ingredientTokenMatch || (genericKey.length > 0 && tokens.every((token) => genericKey.includes(token)))) { return { score: 52, matchType: "ingredient" }; } return null; } function scoreEntry( entry: EmaCatalogEntry, candidate: string ): { score: number; matchType: "brand" | "ingredient" } | null { return scoreNameAndIngredient(entry.nameKey, [entry.genericKey, entry.activeSubstanceKey].filter(Boolean), candidate); } function getSearchSourcePriority(source: MedicationEnrichmentSearchSource): number { if (source === "rxnorm") return 0; if (source === "openfda") return 1; return 2; } function compareSearchResults( left: MedicationEnrichmentSearchResult & { score: number }, right: MedicationEnrichmentSearchResult & { score: number } ): number { return ( right.packageOptions.length - left.packageOptions.length || getSearchSourcePriority(left.source) - getSearchSourcePriority(right.source) || right.score - left.score || left.name.localeCompare(right.name) ); } function collectEmaSearchResults( snapshot: EmaCatalogSnapshot, query: string ): Array { const candidates = getSearchCandidates(query); const byCode = new Map(); for (const entry of snapshot.entries) { let bestMatch: { score: number; matchType: "brand" | "ingredient" } | null = null; for (const candidate of candidates) { const match = scoreEntry(entry, candidate); if (!match) continue; if (!bestMatch || match.score > bestMatch.score) { bestMatch = match; } } if (!bestMatch) continue; const current = byCode.get(entry.code); if (current && current.score >= bestMatch.score) continue; byCode.set(entry.code, { code: entry.code, name: entry.name, genericName: entry.genericName, authorisationHolder: entry.authorisationHolder, therapeuticArea: entry.therapeuticArea, matchType: bestMatch.matchType, genericStatus: entry.genericStatus, authorisationDate: entry.authorisationDate, source: "ema", packageOptions: [], score: bestMatch.score, }); } return [...byCode.values()].sort(compareSearchResults); } function selectCatalogEntry( snapshot: EmaCatalogSnapshot, request: MedicationEnrichmentEnrichRequest ): EmaCatalogEntry | null { if (request.source === "ema" && request.code) { return snapshot.entries.find((entry) => entry.code === request.code) ?? null; } const normalizedName = normalizeSearchText(request.name); const normalizedGeneric = request.genericName ? normalizeSearchText(request.genericName) : ""; const normalizedQuery = normalizeSearchText(request.query); const candidates = [normalizedName, normalizedGeneric, normalizedQuery].filter(Boolean); let bestEntry: EmaCatalogEntry | null = null; let bestScore = 0; for (const entry of snapshot.entries) { for (const candidate of candidates) { const match = scoreEntry(entry, candidate); if (!match || match.score <= bestScore) continue; bestEntry = entry; bestScore = match.score; } } return bestEntry; } function uniqueStrengthOptions(options: MedicationEnrichmentStrengthOption[]): MedicationEnrichmentStrengthOption[] { const deduped = new Map(); for (const option of options) { if (!deduped.has(option.label)) { deduped.set(option.label, option); } } return [...deduped.values()]; } function normalizeDoseUnit(unit: string): MedicationEnrichmentStrengthOption["doseUnit"] { const normalized = unit.toLowerCase(); if (normalized === "mg") return "mg"; if (normalized === "g") return "g"; if (normalized === "mcg" || normalized === "μg") return "mcg"; if (normalized === "ml") return "ml"; if (normalized === "iu") return "IU"; if (normalized === "units" || normalized === "unit") return "units"; if (normalized === "drops" || normalized === "drop") return "drops"; if (normalized === "puffs" || normalized === "puff") return "puffs"; return null; } function parseStrengthOption(label: string): MedicationEnrichmentStrengthOption | null { const match = label.match(/(\d+(?:\.\d+)?)\s*(mg|mcg|g|ml|iu|units?)\b/i); if (!match) return null; const numericValue = Number(match[1]); if (!Number.isFinite(numericValue)) return null; const doseUnit = normalizeDoseUnit(match[2]); let pillWeightMg: number | null = null; if (doseUnit === "mg") { pillWeightMg = numericValue; } else if (doseUnit === "g") { pillWeightMg = numericValue * 1000; } else if (doseUnit === "mcg") { pillWeightMg = numericValue / 1000; } return { label: `${match[1]} ${doseUnit ?? match[2].toLowerCase()}`, pillWeightMg, doseUnit, }; } function deriveMedicationFormFromName(value: string): "capsule" | "tablet" | "liquid" | "topical" | null { const normalized = value.toLowerCase(); if (normalized.includes("capsule")) return "capsule"; if (normalized.includes("tablet")) return "tablet"; if ( normalized.includes("solution") || normalized.includes("suspension") || normalized.includes("syrup") || normalized.includes("oral liquid") ) { return "liquid"; } if ( normalized.includes("cream") || normalized.includes("ointment") || normalized.includes("gel") || normalized.includes("lotion") || normalized.includes("spray") || normalized.includes("patch") ) { return "topical"; } return null; } function extractBracketedBrand(value: string): string | null { const match = /\[([^\]]+)\]\s*$/.exec(value); return match?.[1] ? sanitizeText(match[1]) : null; } function stripBracketedBrand(value: string): string { return value.replace(/\s*\[[^\]]+\]\s*$/, "").trim(); } function extractIngredientName(value: string): string | null { const sanitized = sanitizeText(stripBracketedBrand(value)); if (!sanitized) return null; const beforeStrength = sanitized.split(/\s+\d+(?:[.,]\d+)?\s*(?:mg|mcg|g|ml|iu|units?)\b/i)[0]?.trim() ?? sanitized; const ingredient = beforeStrength.split("/")[0]?.trim() ?? beforeStrength; return ingredient.length > 0 ? ingredient : sanitized; } function isRxNormSearchGroup(group: RxNormDrugConceptGroup): boolean { return group.tty === "SBD" || group.tty === "SCD"; } function buildRxNormSearchResult(property: RxNormDrugConceptProperty): MedicationEnrichmentSearchResult | null { const code = sanitizeText(property.rxcui); const rawName = sanitizeText(property.name); if (!code || !rawName) return null; const bracketBrand = extractBracketedBrand(rawName); const synonym = sanitizeText(property.synonym); const genericName = extractIngredientName(rawName); const displayName = bracketBrand ?? synonym ?? stripBracketedBrand(rawName); if (!displayName) return null; return { code, name: displayName, genericName, authorisationHolder: null, therapeuticArea: null, matchType: bracketBrand ? "brand" : "ingredient", genericStatus: "unknown", authorisationDate: null, source: "rxnorm", packageOptions: [], }; } async function fetchRxNormSearchResults(query: string, limit: number): Promise { const byCode = new Map(); const candidates = getSearchCandidates(query); for (const candidate of candidates) { let payload: RxNormDrugsResponse; try { payload = (await fetchJson( `${RXNORM_BASE_URL}/drugs.json?name=${encodeURIComponent(candidate)}`, RXNORM_FETCH_TIMEOUT_MS )) as RxNormDrugsResponse; } catch (error) { activeLogger.warn( `[MedicationEnrichment] RxNorm search failed for "${candidate}": ${error instanceof Error ? error.message : "unknown"}` ); continue; } for (const group of payload.drugGroup?.conceptGroup ?? []) { if (!isRxNormSearchGroup(group)) continue; for (const property of group.conceptProperties ?? []) { const result = buildRxNormSearchResult(property); if (!result) continue; const match = scoreNameAndIngredient( normalizeSearchText(result.name), result.genericName ? [normalizeSearchText(result.genericName)] : [], candidate ); if (!match) continue; const score = match.score - (group.tty === "SCD" ? 6 : 0); const current = byCode.get(result.code); if (current && current.score >= score) continue; byCode.set(result.code, { ...result, matchType: match.matchType, score, }); } } } return [...byCode.values()] .sort((left, right) => right.score - left.score || left.name.localeCompare(right.name)) .slice(0, Math.min(limit, RXNORM_SEARCH_LIMIT)) .map(({ score: _score, ...result }) => result); } async function fetchRxNormEnrichmentByRxcui(rxcui: string): Promise { if (!rxcui) return null; const relatedPayload = (await fetchJson( `${RXNORM_BASE_URL}/rxcui/${encodeURIComponent(rxcui)}/related.json?tty=SCD+SBD`, RXNORM_FETCH_TIMEOUT_MS )) as RxNormRelatedResponse; const conceptNames = relatedPayload.relatedGroup?.conceptGroup?.flatMap((group) => (group.conceptProperties ?? []) .map((property) => sanitizeText(property.name)) .filter((value): value is string => value !== null) ) ?? []; if (conceptNames.length === 0) return null; const strengthOptions = uniqueStrengthOptions( conceptNames .map((name) => parseStrengthOption(name)) .filter((value): value is MedicationEnrichmentStrengthOption => value !== null) ); const medicationForm = conceptNames .map(deriveMedicationFormFromName) .find((value): value is NonNullable => value !== null) ?? null; return { strengthOptions, medicationForm }; } async function fetchRxNormEnrichment(term: string): Promise { if (!term) return null; const payload = (await fetchJson( `${RXNORM_BASE_URL}/rxcui.json?name=${encodeURIComponent(term)}&search=2`, RXNORM_FETCH_TIMEOUT_MS )) as { idGroup?: { rxnormId?: string[] }; }; const rxcui = payload.idGroup?.rxnormId?.[0]; if (!rxcui) return null; return fetchRxNormEnrichmentByRxcui(rxcui); } function buildOpenFdaSearchUrls(query: string): string[] { const raw = query.trim(); const withoutStrength = removeStrengthHints(raw); const normalizedIngredient = normalizeIngredientTerm(raw); const normalizedIngredientWithoutStrength = normalizeIngredientTerm(withoutStrength); const searchTerms = [ { field: "brand_name", value: raw }, { field: "brand_name", value: withoutStrength }, { field: "generic_name", value: normalizedIngredient }, { field: "generic_name", value: normalizedIngredientWithoutStrength }, ].filter((entry) => entry.value.length > 0); return [...new Set(searchTerms.map((entry) => `${entry.field}:${entry.value}`))].map((entry) => { const [field, value] = entry.split(":"); const params = new URLSearchParams({ search: `${field}:"${value}"`, limit: String(OPENFDA_SEARCH_LIMIT), }); return `${OPENFDA_NDC_URL}?${params.toString()}`; }); } function normalizeOpenFdaName(value: unknown): string | null { const sanitized = sanitizeText(value); if (!sanitized) return null; return sanitized .split(/\s*;\s*/)[0] .replace(/\s+/g, " ") .trim(); } function normalizeOpenFdaPackagingText(value: string): string { return value .toUpperCase() .replace(/[^A-Z0-9]+/g, " ") .replace(/\s+/g, " ") .trim(); } function parseOpenFdaPackagingSegment(segment: string): ParsedOpenFdaPackagingSegment | null { const sanitized = sanitizeText(segment); if (!sanitized) return null; const match = /^(\d+(?:[.,]\d+)?)\s+(.+?)\s+in\s+(\d+(?:[.,]\d+)?)\s+(.+)$/i.exec(sanitized); if (!match) return null; const quantity = Number(match[1].replace(",", ".")); const containerCount = Number(match[3].replace(",", ".")); if (!Number.isFinite(quantity) || quantity <= 0 || !Number.isFinite(containerCount) || containerCount <= 0) { return null; } return { quantity, itemText: match[2].trim(), containerCount, containerText: match[4].trim(), }; } function isOpenFdaBlisterLikeContainer(containerText: string): boolean { const normalized = normalizeOpenFdaPackagingText(containerText); return ( normalized.includes("BLISTER") || normalized.includes("POUCH") || normalized.includes("STRIP") || normalized.includes("SACHET") ); } function isOpenFdaBottleLikeContainer(containerText: string): boolean { const normalized = normalizeOpenFdaPackagingText(containerText); return normalized.includes("BOTTLE") || normalized.includes("JAR") || normalized.includes("VIAL"); } function isOpenFdaSolidUnit(itemText: string): boolean { const normalized = normalizeOpenFdaPackagingText(itemText); return ( normalized.includes("TABLET") || normalized.includes("CAPSULE") || normalized.includes("CAPLET") || normalized.includes("SOFTGEL") || normalized.includes("LOZENGE") ); } function getOpenFdaAmountUnit(itemText: string): "ml" | "g" | null { const normalized = normalizeOpenFdaPackagingText(itemText); if (normalized.includes("ML")) return "ml"; if (normalized === "G" || normalized.startsWith("G ") || normalized.includes("GRAM")) return "g"; return null; } function toPositiveInteger(value: number): number | null { if (!Number.isFinite(value) || value <= 0) return null; return Math.round(value); } function uniquePackageOptions(options: MedicationEnrichmentPackageOption[]): MedicationEnrichmentPackageOption[] { const byKey = new Map(); for (const option of options) { const key = JSON.stringify([ option.description, option.packageType, option.packCount, option.blistersPerPack, option.pillsPerBlister, option.totalPills, option.packageAmountValue, option.packageAmountUnit, ]); if (!byKey.has(key)) { byKey.set(key, option); } } return [...byKey.values()]; } function buildOpenFdaPackageOptions(product: OpenFdaProduct): MedicationEnrichmentPackageOption[] { return uniquePackageOptions( (product.packaging ?? []) .map((entry): MedicationEnrichmentPackageOption | null => { const description = sanitizeText(entry.description); if (!description) return null; const segments = description.split(/\s*\/\s*/).filter((value) => value.trim().length > 0); const outerSegment = segments.length > 1 ? parseOpenFdaPackagingSegment(segments[0] ?? "") : null; const primarySegment = parseOpenFdaPackagingSegment(segments[segments.length - 1] ?? ""); if (!primarySegment) return null; const packCount = toPositiveInteger(outerSegment?.quantity ?? 1) ?? 1; const packageAmountUnit = getOpenFdaAmountUnit(primarySegment.itemText); if (packageAmountUnit) { const packageAmountValue = toPositiveInteger(primarySegment.quantity); if (packageAmountValue === null) return null; const totalAmount = packCount * packageAmountValue; return { label: description, description, packageType: packageAmountUnit === "g" ? "tube" : "liquid_container", packCount, blistersPerPack: null, pillsPerBlister: null, totalPills: totalAmount, looseTablets: totalAmount, packageAmountValue, packageAmountUnit, } satisfies MedicationEnrichmentPackageOption; } if (!isOpenFdaSolidUnit(primarySegment.itemText)) return null; const pillsPerUnit = toPositiveInteger(primarySegment.quantity); if (pillsPerUnit === null) return null; if (isOpenFdaBlisterLikeContainer(primarySegment.containerText)) { return { label: description, description, packageType: "blister", packCount: 1, blistersPerPack: packCount, pillsPerBlister: pillsPerUnit, totalPills: packCount * pillsPerUnit, looseTablets: 0, packageAmountValue: null, packageAmountUnit: null, } satisfies MedicationEnrichmentPackageOption; } if (isOpenFdaBottleLikeContainer(primarySegment.containerText) || outerSegment === null) { const totalPills = packCount * pillsPerUnit; return { label: description, description, packageType: "bottle", packCount, blistersPerPack: null, pillsPerBlister: null, totalPills, looseTablets: totalPills, packageAmountValue: null, packageAmountUnit: null, } satisfies MedicationEnrichmentPackageOption; } return null; }) .filter((value): value is MedicationEnrichmentPackageOption => value !== null) ); } function buildOpenFdaSearchResult(product: OpenFdaProduct): MedicationEnrichmentSearchResult | null { const code = sanitizeText(product.product_ndc) ?? sanitizeText(product.product_id); const name = normalizeOpenFdaName(product.brand_name) ?? normalizeOpenFdaName(product.generic_name); if (!code || !name) return null; const genericName = normalizeOpenFdaName(product.generic_name) ?? normalizeOpenFdaName(product.active_ingredients?.[0]?.name) ?? null; const nameKey = normalizeSearchText(name); const genericKey = genericName ? normalizeSearchText(genericName) : ""; const match = scoreNameAndIngredient(nameKey, genericKey ? [genericKey] : [], nameKey) ?? { score: 40, matchType: genericKey === nameKey ? "ingredient" : "brand", }; return { code, name, genericName, authorisationHolder: null, therapeuticArea: null, matchType: match.matchType, genericStatus: "unknown", authorisationDate: parseCompactDate(product.marketing_start_date), source: "openfda", packageOptions: buildOpenFdaPackageOptions(product), }; } async function fetchOpenFdaSearchResults(query: string, limit: number): Promise { const byCode = new Map(); const candidates = getSearchCandidates(query); for (const url of buildOpenFdaSearchUrls(query)) { let payload: OpenFdaResponse; try { payload = (await fetchJson(url, OPENFDA_FETCH_TIMEOUT_MS)) as OpenFdaResponse; } catch (error) { if (error instanceof Error && error.message === "HTTP_404") { continue; } activeLogger.warn( `[MedicationEnrichment] openFDA search failed for "${query}": ${error instanceof Error ? error.message : "unknown"}` ); continue; } for (const product of payload.results ?? []) { const result = buildOpenFdaSearchResult(product); if (!result) continue; let bestScore = 0; let bestMatchType: "brand" | "ingredient" = result.matchType; for (const candidate of candidates) { const match = scoreNameAndIngredient( normalizeSearchText(result.name), result.genericName ? [normalizeSearchText(result.genericName)] : [], candidate ); if (!match || match.score <= bestScore) continue; bestScore = match.score; bestMatchType = match.matchType; } if (bestScore === 0) continue; const score = bestScore - 12; const current = byCode.get(result.code); if (current && current.score >= score) continue; byCode.set(result.code, { ...result, matchType: bestMatchType, score, }); } } return [...byCode.values()] .sort((left, right) => right.score - left.score || left.name.localeCompare(right.name)) .slice(0, Math.min(limit, OPENFDA_SEARCH_LIMIT)) .map(({ score: _score, ...result }) => result); } function buildOpenFdaStrengthOptions(product: OpenFdaProduct): MedicationEnrichmentStrengthOption[] { return uniqueStrengthOptions( (product.active_ingredients ?? []) .map((ingredient) => sanitizeText(ingredient.strength)) .filter((value): value is string => value !== null) .map((strength) => strength.split("/")[0]?.trim() ?? strength) .map((strength) => parseStrengthOption(strength)) .filter((value): value is MedicationEnrichmentStrengthOption => value !== null) ); } function buildOpenFdaEnrichment(product: OpenFdaProduct): OpenFdaEnrichment | null { const name = normalizeOpenFdaName(product.brand_name) ?? normalizeOpenFdaName(product.generic_name); if (!name) return null; const genericName = normalizeOpenFdaName(product.generic_name) ?? normalizeOpenFdaName(product.active_ingredients?.[0]?.name) ?? null; return { name, genericName, strengthOptions: buildOpenFdaStrengthOptions(product), medicationForm: product.dosage_form ? deriveMedicationFormFromName(product.dosage_form) : null, packageOptions: buildOpenFdaPackageOptions(product), }; } async function fetchOpenFdaProductByCode(code: string): Promise { if (!code) return null; const params = new URLSearchParams({ search: `product_ndc:"${code}"`, limit: "1", }); try { const payload = (await fetchJson( `${OPENFDA_NDC_URL}?${params.toString()}`, OPENFDA_FETCH_TIMEOUT_MS )) as OpenFdaResponse; return payload.results?.[0] ?? null; } catch (error) { if (error instanceof Error && error.message === "HTTP_404") { return null; } throw error; } } async function fetchOpenFdaEnrichmentByQuery(query: string): Promise { for (const url of buildOpenFdaSearchUrls(query)) { try { const payload = (await fetchJson(url, OPENFDA_FETCH_TIMEOUT_MS)) as OpenFdaResponse; const enrichment = payload.results ?.map(buildOpenFdaEnrichment) .find((value): value is OpenFdaEnrichment => value !== null); if (enrichment) return enrichment; } catch (error) { if (error instanceof Error && error.message === "HTTP_404") { continue; } throw error; } } return null; } function mergeStrengthOptions( rxNormOptions: MedicationEnrichmentStrengthOption[], openFdaOptions: MedicationEnrichmentStrengthOption[] ): MedicationEnrichmentStrengthOption[] { return uniqueStrengthOptions([...rxNormOptions, ...openFdaOptions]); } function buildCombinedSource( primarySource: MedicationEnrichmentSearchSource, rxNormMatched: boolean, openFdaMatched: boolean ): MedicationEnrichmentCombinedSource { const sources = new Set([primarySource]); if (rxNormMatched) sources.add("rxnorm"); if (openFdaMatched) sources.add("openfda"); const ordered = ["ema", "rxnorm", "openfda"].filter((source): source is MedicationEnrichmentSearchSource => sources.has(source as MedicationEnrichmentSearchSource) ); return ordered.join("+") as MedicationEnrichmentCombinedSource; } function buildSelectionNameAndGeneric(request: MedicationEnrichmentEnrichRequest): { name: string; genericName: string | null; } { const genericName = request.genericName?.trim() ? request.genericName.trim() : extractIngredientName(request.name); return { name: request.name, genericName, }; } export function startMedicationEnrichmentService(logger: MedicationEnrichmentLogger): void { activeLogger = logger; if (schedulerStarted) return; schedulerStarted = true; void refreshEmaCatalog("startup").catch((error: unknown) => { activeLogger.error( `[MedicationEnrichment] startup refresh failed: ${error instanceof Error ? error.message : String(error)}` ); return undefined; }); refreshTimer = setInterval(() => { void refreshEmaCatalog("scheduled").catch((error: unknown) => { activeLogger.error( `[MedicationEnrichment] scheduled refresh failed: ${error instanceof Error ? error.message : String(error)}` ); return undefined; }); }, EMA_REFRESH_INTERVAL_MS); if (typeof refreshTimer.unref === "function") { refreshTimer.unref(); } } export const startMedicationEnrichmentCatalogRefresh = startMedicationEnrichmentService; export async function searchMedicationEnrichment( query: string, limit: number ): Promise { const cappedLimit = Math.min(limit, MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT); const fetchLimit = MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT; const byCode = new Map(); const [rxNormResults, openFdaResults] = await Promise.all([ fetchRxNormSearchResults(query, fetchLimit), fetchOpenFdaSearchResults(query, fetchLimit), ]); for (const result of rxNormResults) { if (byCode.has(result.code)) continue; const score = result.matchType === "brand" ? 82 : 74; byCode.set(result.code, { ...result, score }); } for (const result of openFdaResults) { if (byCode.has(result.code)) continue; const score = result.matchType === "brand" ? 68 : 60; byCode.set(result.code, { ...result, score }); } if (byCode.size < fetchLimit) { const snapshot = await ensureEmaCatalogReady(); const emaResults = collectEmaSearchResults(snapshot, query); for (const result of emaResults) { if (byCode.has(result.code)) continue; byCode.set(result.code, result); if (byCode.size >= fetchLimit) break; } } const orderedResults = [...byCode.values()].sort(compareSearchResults); return { query, normalizedQuery: normalizeSearchText(query), hasMore: orderedResults.length > cappedLimit, results: orderedResults.slice(0, cappedLimit).map(({ score: _score, ...result }) => result), }; } export async function enrichMedicationSelection( request: MedicationEnrichmentEnrichRequest, logger?: MedicationEnrichmentLogger ): Promise { const loggerToUse = logger ?? activeLogger; const requestedSource = request.source ?? "ema"; let rxNormMatched = false; let openFdaMatched = false; let partial = false; let note: string | null = null; let rxNormEnrichment: RxNormEnrichment | null = null; let openFdaEnrichment: OpenFdaEnrichment | null = null; if (requestedSource === "ema") { const snapshot = await ensureEmaCatalogReady(); const selectedEntry = selectCatalogEntry(snapshot, request); if (!selectedEntry) { throw new MedicationEnrichmentServiceError( "Selected medication could not be resolved.", 404, "MEDICATION_ENRICHMENT_NOT_FOUND" ); } const rxNormTerm = normalizeIngredientTerm(request.genericName ?? selectedEntry.genericName ?? request.query); try { rxNormEnrichment = await fetchRxNormEnrichment(rxNormTerm); rxNormMatched = rxNormEnrichment !== null && (rxNormEnrichment.medicationForm !== null || rxNormEnrichment.strengthOptions.length > 0); } catch (error) { partial = true; note = "Returned EMA enrichment without RxNorm suggestions."; loggerToUse.warn( `[MedicationEnrichment] RxNorm enrichment failed: ${error instanceof Error ? error.message : "unknown"}` ); } if (!rxNormMatched) { try { openFdaEnrichment = await fetchOpenFdaEnrichmentByQuery( selectedEntry.genericName ?? request.genericName ?? request.query ); openFdaMatched = openFdaEnrichment !== null && (openFdaEnrichment.medicationForm !== null || openFdaEnrichment.strengthOptions.length > 0 || openFdaEnrichment.packageOptions.length > 0); } catch (error) { partial = true; note = note ?? "Returned EMA enrichment without secondary-source suggestions."; loggerToUse.warn( `[MedicationEnrichment] openFDA enrichment failed: ${error instanceof Error ? error.message : "unknown"}` ); } } return { selection: { name: selectedEntry.name, genericName: selectedEntry.genericName, therapeuticArea: selectedEntry.therapeuticArea, indication: selectedEntry.indication, atcCode: selectedEntry.atcCode, source: buildCombinedSource("ema", rxNormMatched, openFdaMatched), }, suggestions: { name: selectedEntry.name, genericName: selectedEntry.genericName, medicationForm: rxNormEnrichment?.medicationForm ?? openFdaEnrichment?.medicationForm ?? null, strengthOptions: mergeStrengthOptions( rxNormEnrichment?.strengthOptions ?? [], openFdaEnrichment?.strengthOptions ?? [] ), packageOptions: openFdaEnrichment?.packageOptions ?? [], }, meta: { rxNormMatched, openFdaMatched, partial, note, }, }; } if (requestedSource === "rxnorm") { if (!request.code) { throw new MedicationEnrichmentServiceError( "Selected medication could not be resolved.", 404, "MEDICATION_ENRICHMENT_NOT_FOUND" ); } const selection = buildSelectionNameAndGeneric(request); try { rxNormEnrichment = await fetchRxNormEnrichmentByRxcui(request.code); rxNormMatched = rxNormEnrichment !== null && (rxNormEnrichment.medicationForm !== null || rxNormEnrichment.strengthOptions.length > 0); } catch (error) { partial = true; note = "Returned RxNorm enrichment without strength suggestions."; loggerToUse.warn( `[MedicationEnrichment] RxNorm enrich-by-code failed: ${error instanceof Error ? error.message : "unknown"}` ); } if (!rxNormMatched) { try { openFdaEnrichment = await fetchOpenFdaEnrichmentByQuery(selection.genericName ?? selection.name); openFdaMatched = openFdaEnrichment !== null && (openFdaEnrichment.medicationForm !== null || openFdaEnrichment.strengthOptions.length > 0 || openFdaEnrichment.packageOptions.length > 0); } catch (error) { partial = true; note = note ?? "Returned RxNorm enrichment without openFDA suggestions."; loggerToUse.warn( `[MedicationEnrichment] openFDA fallback failed after RxNorm match: ${error instanceof Error ? error.message : "unknown"}` ); } } return { selection: { name: selection.name, genericName: selection.genericName, therapeuticArea: null, indication: null, atcCode: null, source: buildCombinedSource("rxnorm", rxNormMatched, openFdaMatched), }, suggestions: { name: selection.name, genericName: selection.genericName, medicationForm: rxNormEnrichment?.medicationForm ?? openFdaEnrichment?.medicationForm ?? null, strengthOptions: mergeStrengthOptions( rxNormEnrichment?.strengthOptions ?? [], openFdaEnrichment?.strengthOptions ?? [] ), packageOptions: openFdaEnrichment?.packageOptions ?? [], }, meta: { rxNormMatched, openFdaMatched, partial, note, }, }; } if (!request.code) { throw new MedicationEnrichmentServiceError( "Selected medication could not be resolved.", 404, "MEDICATION_ENRICHMENT_NOT_FOUND" ); } let product: OpenFdaProduct | null = null; try { product = await fetchOpenFdaProductByCode(request.code); } catch (error) { loggerToUse.warn( `[MedicationEnrichment] openFDA product lookup failed: ${error instanceof Error ? error.message : "unknown"}` ); } if (!product) { throw new MedicationEnrichmentServiceError( "Selected medication could not be resolved.", 404, "MEDICATION_ENRICHMENT_NOT_FOUND" ); } openFdaEnrichment = buildOpenFdaEnrichment(product); openFdaMatched = openFdaEnrichment !== null && (openFdaEnrichment.medicationForm !== null || openFdaEnrichment.strengthOptions.length > 0 || openFdaEnrichment.packageOptions.length > 0); const openFdaGeneric = openFdaEnrichment?.genericName ?? request.genericName ?? request.name; try { rxNormEnrichment = await fetchRxNormEnrichment(normalizeIngredientTerm(openFdaGeneric)); rxNormMatched = rxNormEnrichment !== null && (rxNormEnrichment.medicationForm !== null || rxNormEnrichment.strengthOptions.length > 0); } catch (error) { partial = true; note = "Returned openFDA enrichment without RxNorm suggestions."; loggerToUse.warn( `[MedicationEnrichment] RxNorm fallback failed after openFDA match: ${error instanceof Error ? error.message : "unknown"}` ); } return { selection: { name: openFdaEnrichment?.name ?? request.name, genericName: openFdaEnrichment?.genericName ?? request.genericName ?? null, therapeuticArea: null, indication: null, atcCode: null, source: buildCombinedSource("openfda", rxNormMatched, openFdaMatched), }, suggestions: { name: openFdaEnrichment?.name ?? request.name, genericName: openFdaEnrichment?.genericName ?? request.genericName ?? null, medicationForm: rxNormEnrichment?.medicationForm ?? openFdaEnrichment?.medicationForm ?? null, strengthOptions: mergeStrengthOptions( rxNormEnrichment?.strengthOptions ?? [], openFdaEnrichment?.strengthOptions ?? [] ), packageOptions: openFdaEnrichment?.packageOptions ?? [], }, meta: { rxNormMatched, openFdaMatched, partial, note, }, }; }