From b796e03bcb7d5b4a70de02ebb3349cef1127e451 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Fri, 20 Mar 2026 20:39:38 +0100 Subject: [PATCH] feat: add medication enrichment lookup to the medication editor * feat: add medication enrichment lookup * fix: avoid double unescape in enrichment sanitization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 6 + backend/src/index.ts | 12 + backend/src/routes/medication-enrichment.ts | 223 +++ backend/src/services/medication-enrichment.ts | 1208 +++++++++++++++++ .../src/test/medication-enrichment.test.ts | 541 ++++++++ .../MedicationEnrichmentSection.tsx | 283 ++++ frontend/src/components/MobileEditModal.tsx | 49 +- frontend/src/components/index.ts | 2 + frontend/src/i18n/de.json | 44 + frontend/src/i18n/en.json | 44 + frontend/src/pages/MedicationsPage.tsx | 350 ++++- frontend/src/styles.css | 205 +++ .../MedicationEnrichmentSection.test.tsx | 254 ++++ .../test/components/MobileEditModal.test.tsx | 79 ++ .../src/test/pages/MedicationsPage.test.tsx | 154 +++ frontend/src/types/index.ts | 58 + 16 files changed, 3510 insertions(+), 2 deletions(-) create mode 100644 backend/src/routes/medication-enrichment.ts create mode 100644 backend/src/services/medication-enrichment.ts create mode 100644 backend/src/test/medication-enrichment.test.ts create mode 100644 frontend/src/components/MedicationEnrichmentSection.tsx create mode 100644 frontend/src/test/components/MedicationEnrichmentSection.test.tsx diff --git a/README.md b/README.md index 9998696..5bedfd9 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,12 @@ Share your medication schedule with others via a public link. +### Medication Setup +- Optional multi-source lookup inside the medication editor on desktop and mobile, prioritizing `RxNorm` and `openFDA` before `EMA` +- Explicit review-and-apply flow with low-risk suggestions only +- Additional lookup results can be revealed on demand instead of being hard-cut at the initial small result set +- Honest incomplete-coverage messaging with source labels; manual entry always remains available + ### Smart Inventory - Track exact stock with package profiles (blister, bottle, tube, liquid container) - Display remaining days of supply diff --git a/backend/src/index.ts b/backend/src/index.ts index d98c8f8..e155f44 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -21,6 +21,7 @@ import { authRoutes } from "./routes/auth.js"; import { doseRoutes } from "./routes/doses.js"; import { exportRoutes } from "./routes/export.js"; import { healthRoutes } from "./routes/health.js"; +import { medicationEnrichmentRoutes } from "./routes/medication-enrichment.js"; import { medicationRoutes } from "./routes/medications.js"; import { oidcRoutes } from "./routes/oidc.js"; import { plannerRoutes } from "./routes/planner.js"; @@ -29,6 +30,7 @@ import { reportRoutes } from "./routes/report.js"; import { settingsRoutes } from "./routes/settings.js"; import { shareRoutes } from "./routes/share.js"; import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js"; +import { startMedicationEnrichmentCatalogRefresh } from "./services/medication-enrichment.js"; import { startReminderScheduler } from "./services/reminder-scheduler.js"; import { documentationSchemaAjv } from "./utils/documentation-schema-keywords.js"; @@ -93,6 +95,7 @@ async function registerApiDocs(app: FastifyInstance, enabled: boolean) { { name: "health", description: "Service health endpoints" }, { name: "auth", description: "Authentication and profile endpoints" }, { name: "api-keys", description: "Programmatic API key management" }, + { name: "medication-enrichment", description: "Medication search and enrichment endpoints" }, { name: "settings", description: "User settings and notification test endpoints" }, ], components: { @@ -206,6 +209,7 @@ export async function createApp(options?: { await app.register(apiKeyRoutes); await app.register(oidcRoutes); await app.register(medicationRoutes); + await app.register(medicationEnrichmentRoutes); await app.register(settingsRoutes); await app.register(plannerRoutes); await app.register(shareRoutes); @@ -287,6 +291,7 @@ await app.register(authRoutes); await app.register(apiKeyRoutes); await app.register(oidcRoutes); await app.register(medicationRoutes); +await app.register(medicationEnrichmentRoutes); await app.register(settingsRoutes); await app.register(plannerRoutes); await app.register(shareRoutes); @@ -307,6 +312,13 @@ const start = async () => { error: (msg) => app.log.error(msg), }); + startMedicationEnrichmentCatalogRefresh({ + info: (msg: string) => app.log.info(msg), + debug: (msg: string) => app.log.debug(msg), + warn: (msg: string) => app.log.warn(msg), + error: (msg: string) => app.log.error(msg), + }); + // Start the intake reminder scheduler (checks every minute) startIntakeReminderScheduler({ info: (msg) => app.log.info(msg), diff --git a/backend/src/routes/medication-enrichment.ts b/backend/src/routes/medication-enrichment.ts new file mode 100644 index 0000000..cd83b1d --- /dev/null +++ b/backend/src/routes/medication-enrichment.ts @@ -0,0 +1,223 @@ +import type { FastifyInstance, FastifyReply } from "fastify"; +import { z } from "zod"; +import { requireAuth } from "../plugins/auth.js"; +import { + enrichMedicationSelection, + MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT, + MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT, + type MedicationEnrichmentEnrichRequest, + MedicationEnrichmentServiceError, + searchMedicationEnrichment, +} from "../services/medication-enrichment.js"; +import { + applyOpenApiRouteStandards, + genericErrorSchema, + validationErrorSchema, +} from "../utils/openapi-route-standards.js"; + +const searchQuerySchema = z.object({ + q: z.string().trim().min(1).max(120), + limit: z.coerce + .number() + .int() + .min(1) + .max(MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT) + .default(MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT), +}); + +const enrichBodySchema = z.object({ + query: z.string().trim().min(1).max(120), + name: z.string().trim().min(1).max(140), + genericName: z.string().trim().max(140).nullable().optional(), + code: z.string().trim().min(1).max(160).nullable().optional(), + source: z.enum(["ema", "rxnorm", "openfda"]).nullable().optional(), +}); + +const searchQueryOpenApiSchema = { + type: "object", + required: ["q"], + properties: { + q: { type: "string", minLength: 1, maxLength: 120 }, + limit: { + anyOf: [ + { type: "string", pattern: "^[0-9]+$" }, + { + type: "integer", + minimum: 1, + maximum: MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT, + default: MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT, + }, + ], + }, + }, +} as const; + +const enrichBodyOpenApiSchema = { + type: "object", + required: ["query", "name"], + properties: { + query: { type: "string", minLength: 1, maxLength: 120 }, + name: { type: "string", minLength: 1, maxLength: 140 }, + genericName: { type: "string", nullable: true, maxLength: 140 }, + code: { type: "string", nullable: true, maxLength: 160 }, + source: { type: "string", nullable: true, enum: ["ema", "rxnorm", "openfda"] }, + }, +} as const; + +const strengthOptionSchema = { + type: "object", + properties: { + label: { type: "string" }, + pillWeightMg: { type: "number", nullable: true }, + doseUnit: { + anyOf: [{ type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"] }, { type: "null" }], + }, + }, +} as const; + +const searchResponseSchema = { + type: "object", + properties: { + query: { type: "string" }, + normalizedQuery: { type: "string" }, + hasMore: { type: "boolean" }, + results: { + type: "array", + items: { + type: "object", + properties: { + code: { type: "string" }, + name: { type: "string" }, + genericName: { type: "string", nullable: true }, + authorisationHolder: { type: "string", nullable: true }, + therapeuticArea: { type: "string", nullable: true }, + matchType: { type: "string", enum: ["brand", "ingredient"] }, + genericStatus: { type: "string", enum: ["generic", "original", "unknown"] }, + authorisationDate: { type: "string", nullable: true }, + source: { type: "string", enum: ["ema", "rxnorm", "openfda"] }, + }, + }, + }, + }, +} as const; + +const enrichResponseSchema = { + type: "object", + properties: { + selection: { + type: "object", + properties: { + name: { type: "string" }, + genericName: { type: "string", nullable: true }, + therapeuticArea: { type: "string", nullable: true }, + indication: { type: "string", nullable: true }, + atcCode: { type: "string", nullable: true }, + source: { + type: "string", + enum: ["ema", "rxnorm", "openfda", "ema+rxnorm", "ema+openfda", "rxnorm+openfda", "ema+rxnorm+openfda"], + }, + }, + }, + suggestions: { + type: "object", + properties: { + name: { type: "string" }, + genericName: { type: "string", nullable: true }, + medicationForm: { + anyOf: [{ type: "string", enum: ["capsule", "tablet", "liquid", "topical"] }, { type: "null" }], + }, + strengthOptions: { type: "array", items: strengthOptionSchema }, + }, + }, + meta: { + type: "object", + properties: { + rxNormMatched: { type: "boolean" }, + openFdaMatched: { type: "boolean" }, + partial: { type: "boolean" }, + note: { type: "string", nullable: true }, + }, + }, + }, +} as const; + +function sendServiceError(error: unknown, reply: FastifyReply) { + if (error instanceof MedicationEnrichmentServiceError) { + return reply.status(error.statusCode).send({ error: error.message, code: error.code }); + } + + return reply.status(503).send({ + error: "Medication enrichment request failed.", + code: "MEDICATION_ENRICHMENT_REQUEST_FAILED", + }); +} + +export async function medicationEnrichmentRoutes(app: FastifyInstance) { + app.addHook("preHandler", requireAuth); + applyOpenApiRouteStandards(app, { tag: "medication-enrichment", protectedByDefault: true }); + + app.get( + "/medication-enrichment/search", + { + schema: { + querystring: searchQueryOpenApiSchema, + response: { + 200: searchResponseSchema, + 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, + 401: genericErrorSchema, + 503: genericErrorSchema, + }, + }, + }, + async (request, reply) => { + const parsed = searchQuerySchema.safeParse(request.query); + if (!parsed.success) return reply.status(400).send(parsed.error.format()); + + try { + return await searchMedicationEnrichment(parsed.data.q, parsed.data.limit); + } catch (error) { + request.log.warn( + { + code: + error instanceof MedicationEnrichmentServiceError ? error.code : "MEDICATION_ENRICHMENT_REQUEST_FAILED", + }, + "[MedicationEnrichment] Search request failed" + ); + return sendServiceError(error, reply); + } + } + ); + + app.post<{ Body: MedicationEnrichmentEnrichRequest }>( + "/medication-enrichment/enrich", + { + schema: { + body: enrichBodyOpenApiSchema, + response: { + 200: enrichResponseSchema, + 400: { anyOf: [genericErrorSchema, validationErrorSchema] }, + 401: genericErrorSchema, + 404: genericErrorSchema, + 503: genericErrorSchema, + }, + }, + }, + async (request, reply) => { + const parsed = enrichBodySchema.safeParse(request.body); + if (!parsed.success) return reply.status(400).send(parsed.error.format()); + + try { + return await enrichMedicationSelection(parsed.data, request.log); + } catch (error) { + request.log.warn( + { + code: + error instanceof MedicationEnrichmentServiceError ? error.code : "MEDICATION_ENRICHMENT_REQUEST_FAILED", + }, + "[MedicationEnrichment] Enrich request failed" + ); + return sendServiceError(error, reply); + } + } + ); +} diff --git a/backend/src/services/medication-enrichment.ts b/backend/src/services/medication-enrichment.ts new file mode 100644 index 0000000..fa80f36 --- /dev/null +++ b/backend/src/services/medication-enrichment.ts @@ -0,0 +1,1208 @@ +import type { FastifyBaseLogger } from "fastify"; + +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; +}; + +export type MedicationEnrichmentStrengthOption = { + label: string; + pillWeightMg: number | null; + doseUnit: "mg" | "g" | "mcg" | "ml" | "IU" | "units" | "drops" | "puffs" | 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[]; + }; + 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[]; +}; + +type OpenFdaResponse = { + results?: OpenFdaProduct[]; +}; + +type OpenFdaEnrichment = { + name: string; + genericName: string | null; + strengthOptions: MedicationEnrichmentStrengthOption[]; + medicationForm: "capsule" | "tablet" | "liquid" | "topical" | null; +}; + +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 ( + 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", + 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", + }; +} + +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 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", + }; +} + +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, + }; +} + +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(() => undefined); + + refreshTimer = setInterval(() => { + void refreshEmaCatalog("scheduled").catch(() => 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); + } 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 ?? [] + ), + }, + 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); + } 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 ?? [] + ), + }, + 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); + + 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 ?? [] + ), + }, + meta: { + rxNormMatched, + openFdaMatched, + partial, + note, + }, + }; +} diff --git a/backend/src/test/medication-enrichment.test.ts b/backend/src/test/medication-enrichment.test.ts new file mode 100644 index 0000000..21b765a --- /dev/null +++ b/backend/src/test/medication-enrichment.test.ts @@ -0,0 +1,541 @@ +import sensible from "@fastify/sensible"; +import Fastify, { type FastifyInstance } from "fastify"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js"; + +const { fetchMock, requireAuthMock } = vi.hoisted(() => ({ + fetchMock: vi.fn(), + requireAuthMock: vi.fn(async () => {}), +})); + +vi.mock("../plugins/auth.js", () => ({ + requireAuth: requireAuthMock, +})); + +function jsonResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + } as Response; +} + +function createEmaRow(overrides: Partial> = {}): Record { + return { + category: "Human", + medicine_status: "Authorised", + name_of_medicine: "Aspirin 500 mg tablets", + international_non_proprietary_name_common_name: "Acetylsalicylic acid", + active_substance: "Acetylsalicylic acid", + marketing_authorisation_developer_applicant_holder: "Bayer", + therapeutic_area_mesh: "Pain", + therapeutic_indication: "Pain relief", + atc_code_human: "N02BA01", + generic_or_hybrid: "No", + biosimilar: "No", + marketing_authorisation_date: "01/02/2024", + ema_product_number: "EMA-ASPIRIN", + ...overrides, + }; +} + +async function buildApp(): Promise { + const { medicationEnrichmentRoutes } = await import("../routes/medication-enrichment.js"); + const app = Fastify({ logger: false, ajv: documentationSchemaAjv }); + await app.register(sensible); + await app.register(medicationEnrichmentRoutes); + await app.ready(); + return app; +} + +describe("medication enrichment", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + fetchMock.mockReset(); + requireAuthMock.mockReset(); + requireAuthMock.mockImplementation(async () => {}); + vi.stubGlobal("fetch", fetchMock); + }); + + it("normalizes German ingredient queries for EMA-backed search results", async () => { + const { searchMedicationEnrichment } = await import("../services/medication-enrichment.js"); + + fetchMock.mockImplementation((url: string) => { + if (url.includes("medicines-output-medicines_json-report_en.json")) { + return Promise.resolve( + jsonResponse([ + createEmaRow({ + name_of_medicine: "Tylenol 500 mg tablets", + international_non_proprietary_name_common_name: "Acetaminophen", + active_substance: "Acetaminophen", + ema_product_number: "EMA-TYLENOL", + }), + createEmaRow({ + name_of_medicine: "Ibuprofen 400 mg tablets", + international_non_proprietary_name_common_name: "Ibuprofen", + active_substance: "Ibuprofen", + ema_product_number: "EMA-IBUPROFEN", + }), + ]) + ); + } + if (url.includes("/drugs.json?name=")) { + return Promise.resolve(jsonResponse({ drugGroup: { conceptGroup: [] } })); + } + if (url.includes("api.fda.gov/drug/ndc.json")) { + return Promise.resolve(jsonResponse({ results: [] })); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + + const response = await searchMedicationEnrichment("Paracetamol 500 mg", 5); + + expect(response.normalizedQuery).toBe("paracetamol 500 mg"); + expect(response.results).toHaveLength(1); + expect(response.results[0]).toMatchObject({ + code: "EMA-TYLENOL", + name: "Tylenol 500 mg tablets", + matchType: "ingredient", + source: "ema", + }); + }); + + it("requires auth and returns EMA search results from the route", async () => { + const app = await buildApp(); + fetchMock.mockImplementation((url: string) => { + if (url.includes("/drugs.json?name=")) { + return Promise.resolve(jsonResponse({ drugGroup: { conceptGroup: [] } })); + } + if (url.includes("api.fda.gov/drug/ndc.json")) { + return Promise.resolve(jsonResponse({ results: [] })); + } + if (url.includes("medicines-output-medicines_json-report_en.json")) { + return Promise.resolve(jsonResponse([createEmaRow()])); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + + const response = await app.inject({ + method: "GET", + url: "/medication-enrichment/search?q=aspirin&limit=1", + }); + + expect(response.statusCode).toBe(200); + expect(requireAuthMock).toHaveBeenCalledTimes(1); + expect(response.json()).toMatchObject({ + query: "aspirin", + normalizedQuery: "aspirin", + hasMore: false, + results: [ + { + code: "EMA-ASPIRIN", + name: "Aspirin 500 mg tablets", + source: "ema", + }, + ], + }); + + await app.close(); + }); + + it("falls back from EMA to RxNorm and openFDA search results when EMA has no match", async () => { + const { searchMedicationEnrichment } = await import("../services/medication-enrichment.js"); + + fetchMock.mockImplementation((url: string) => { + if (url.includes("medicines-output-medicines_json-report_en.json")) { + return Promise.resolve(jsonResponse([createEmaRow()])); + } + if (url.includes("/drugs.json?name=semaglutide")) { + return Promise.resolve( + jsonResponse({ + drugGroup: { + conceptGroup: [ + { + tty: "SBD", + conceptProperties: [ + { + rxcui: "12345", + name: "Semaglutide 0.25 MG Oral Tablet [Wegovy]", + synonym: "Wegovy 0.25 mg oral tablet", + }, + ], + }, + ], + }, + }) + ); + } + if (url.includes("api.fda.gov/drug/ndc.json")) { + return Promise.resolve( + jsonResponse({ + results: [ + { + product_ndc: "00011-1111", + brand_name: "Ozempic", + generic_name: "Semaglutide", + dosage_form: "Tablet", + marketing_start_date: "20240101", + }, + ], + }) + ); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + + const response = await searchMedicationEnrichment("Semaglutide", 3); + + expect(response.hasMore).toBe(false); + expect(response.results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "12345", + name: "Wegovy", + genericName: "Semaglutide", + source: "rxnorm", + }), + expect.objectContaining({ + code: "00011-1111", + name: "Ozempic", + genericName: "Semaglutide", + source: "openfda", + }), + ]) + ); + }); + + it("prioritizes RxNorm first, then openFDA, and keeps EMA last", async () => { + const { searchMedicationEnrichment } = await import("../services/medication-enrichment.js"); + + fetchMock.mockImplementation((url: string) => { + if (url.includes("medicines-output-medicines_json-report_en.json")) { + return Promise.resolve(jsonResponse([createEmaRow()])); + } + if (url.includes("/drugs.json?name=")) { + return Promise.resolve( + jsonResponse({ + drugGroup: { + conceptGroup: [ + { + tty: "SBD", + conceptProperties: [ + { + rxcui: "1191", + name: "Aspirin 500 MG Oral Tablet [Aspirin]", + synonym: "Aspirin 500 mg oral tablet", + }, + ], + }, + ], + }, + }) + ); + } + if (url.includes("api.fda.gov/drug/ndc.json")) { + return Promise.resolve( + jsonResponse({ + results: [ + { + product_ndc: "00011-1111", + brand_name: "Bayer Aspirin", + generic_name: "Acetylsalicylic acid", + dosage_form: "Tablet", + marketing_start_date: "20240101", + }, + ], + }) + ); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + + const response = await searchMedicationEnrichment("Aspirin", 3); + + expect(response.hasMore).toBe(false); + expect(response.results).toHaveLength(3); + expect(response.results[0]).toMatchObject({ + code: "1191", + source: "rxnorm", + }); + expect(response.results[1]).toMatchObject({ + code: "00011-1111", + source: "openfda", + }); + expect(response.results[2]).toMatchObject({ + code: "EMA-ASPIRIN", + source: "ema", + }); + }); + + it("validates malformed search requests", async () => { + const app = await buildApp(); + + const response = await app.inject({ + method: "GET", + url: "/medication-enrichment/search?q=", + }); + + expect(response.statusCode).toBe(400); + expect(fetchMock).not.toHaveBeenCalled(); + + await app.close(); + }); + + it("returns enrichment suggestions with optional RxNorm strength data", async () => { + const app = await buildApp(); + fetchMock + .mockResolvedValueOnce( + jsonResponse([ + createEmaRow({ + name_of_medicine: "Tylenol 500 mg tablets", + international_non_proprietary_name_common_name: "Acetaminophen", + active_substance: "Acetaminophen", + ema_product_number: "EMA-TYLENOL", + }), + ]) + ) + .mockResolvedValueOnce(jsonResponse({ idGroup: { rxnormId: ["161"] } })) + .mockResolvedValueOnce( + jsonResponse({ + relatedGroup: { + conceptGroup: [ + { + conceptProperties: [ + { name: "Acetaminophen 500 MG Oral Tablet" }, + { name: "Acetaminophen 650 MG Oral Tablet" }, + ], + }, + ], + }, + }) + ); + + const response = await app.inject({ + method: "POST", + url: "/medication-enrichment/enrich", + payload: { + query: "Paracetamol", + name: "Tylenol 500 mg tablets", + genericName: "Acetaminophen", + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + selection: { + name: "Tylenol 500 mg tablets", + genericName: "Acetaminophen", + source: "ema+rxnorm", + }, + suggestions: { + medicationForm: "tablet", + strengthOptions: [ + { label: "500 mg", pillWeightMg: 500, doseUnit: "mg" }, + { label: "650 mg", pillWeightMg: 650, doseUnit: "mg" }, + ], + }, + meta: { + rxNormMatched: true, + openFdaMatched: false, + partial: false, + note: null, + }, + }); + + await app.close(); + }); + + it("keeps incomplete-coverage messaging honest when RxNorm enrichment fails", async () => { + const { enrichMedicationSelection } = await import("../services/medication-enrichment.js"); + + fetchMock.mockImplementation((url: string) => { + if (url.includes("medicines-output-medicines_json-report_en.json")) { + return Promise.resolve( + jsonResponse([ + createEmaRow({ + name_of_medicine: "Tylenol 500 mg tablets", + international_non_proprietary_name_common_name: "Acetaminophen", + active_substance: "Acetaminophen", + ema_product_number: "EMA-TYLENOL", + }), + ]) + ); + } + if (url.includes("/rxcui.json?name=acetaminophen&search=2")) { + return Promise.reject(new Error("rxnorm timeout")); + } + if (url.includes("api.fda.gov/drug/ndc.json")) { + return Promise.resolve(jsonResponse({ results: [] })); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + + const response = await enrichMedicationSelection({ + query: "Paracetamol", + name: "Tylenol 500 mg tablets", + genericName: "Acetaminophen", + }); + + expect(response.selection.source).toBe("ema"); + expect(response.suggestions.strengthOptions).toEqual([]); + expect(response.meta).toEqual({ + rxNormMatched: false, + openFdaMatched: false, + partial: true, + note: "Returned EMA enrichment without RxNorm suggestions.", + }); + }); + + it("enriches RxNorm selections by code and falls back to openFDA without best-match guessing", async () => { + const { enrichMedicationSelection } = await import("../services/medication-enrichment.js"); + + fetchMock.mockImplementation((url: string) => { + if (url.includes("/rxcui/12345/related.json")) { + return Promise.resolve( + jsonResponse({ + relatedGroup: { + conceptGroup: [], + }, + }) + ); + } + if (url.includes("api.fda.gov/drug/ndc.json")) { + return Promise.resolve( + jsonResponse({ + results: [ + { + product_ndc: "00011-1111", + brand_name: "Ozempic", + generic_name: "Semaglutide", + dosage_form: "Tablet", + active_ingredients: [{ name: "Semaglutide", strength: "2 mg" }], + }, + ], + }) + ); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + + const response = await enrichMedicationSelection({ + query: "Ozempic", + name: "Ozempic", + genericName: "Semaglutide", + code: "12345", + source: "rxnorm", + }); + + expect(response).toMatchObject({ + selection: { + name: "Ozempic", + genericName: "Semaglutide", + source: "rxnorm+openfda", + }, + suggestions: { + medicationForm: "tablet", + strengthOptions: [{ label: "2 mg", pillWeightMg: 2, doseUnit: "mg" }], + }, + meta: { + rxNormMatched: false, + openFdaMatched: true, + partial: false, + note: null, + }, + }); + }); + + it("enriches openFDA selections by code and augments them with RxNorm strength data", async () => { + const { enrichMedicationSelection } = await import("../services/medication-enrichment.js"); + + fetchMock.mockImplementation((url: string) => { + if (url.includes("search=product_ndc%3A%2200011-1111%22")) { + return Promise.resolve( + jsonResponse({ + results: [ + { + product_ndc: "00011-1111", + brand_name: "US Ibuprofen", + generic_name: "Ibuprofen", + dosage_form: "Tablet", + active_ingredients: [{ name: "Ibuprofen", strength: "200 mg" }], + }, + ], + }) + ); + } + if (url.includes("/rxcui.json?name=ibuprofen&search=2")) { + return Promise.resolve(jsonResponse({ idGroup: { rxnormId: ["161"] } })); + } + if (url.includes("/rxcui/161/related.json")) { + return Promise.resolve( + jsonResponse({ + relatedGroup: { + conceptGroup: [ + { + conceptProperties: [ + { name: "Ibuprofen 200 MG Oral Tablet" }, + { name: "Ibuprofen 400 MG Oral Tablet" }, + ], + }, + ], + }, + }) + ); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + + const response = await enrichMedicationSelection({ + query: "US Ibuprofen", + name: "US Ibuprofen", + genericName: "Ibuprofen", + code: "00011-1111", + source: "openfda", + }); + + expect(response).toMatchObject({ + selection: { + name: "US Ibuprofen", + genericName: "Ibuprofen", + source: "rxnorm+openfda", + }, + suggestions: { + medicationForm: "tablet", + strengthOptions: [ + { label: "200 mg", pillWeightMg: 200, doseUnit: "mg" }, + { label: "400 mg", pillWeightMg: 400, doseUnit: "mg" }, + ], + }, + meta: { + rxNormMatched: true, + openFdaMatched: true, + partial: false, + note: null, + }, + }); + }); + + it("returns not found when an explicit selection cannot be resolved", async () => { + const app = await buildApp(); + fetchMock.mockResolvedValueOnce(jsonResponse([createEmaRow()])); + + const response = await app.inject({ + method: "POST", + url: "/medication-enrichment/enrich", + payload: { + query: "Unknown", + name: "Completely Different Medication", + genericName: "No match", + }, + }); + + expect(response.statusCode).toBe(404); + expect(response.json()).toMatchObject({ + code: "MEDICATION_ENRICHMENT_NOT_FOUND", + error: "Selected medication could not be resolved.", + }); + + await app.close(); + }); +}); diff --git a/frontend/src/components/MedicationEnrichmentSection.tsx b/frontend/src/components/MedicationEnrichmentSection.tsx new file mode 100644 index 0000000..983c30f --- /dev/null +++ b/frontend/src/components/MedicationEnrichmentSection.tsx @@ -0,0 +1,283 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { + MedicationEnrichmentEnrichResponse, + MedicationEnrichmentSearchResult, + MedicationEnrichmentStrengthOption, +} from "../types"; +import { formatDate } from "../utils/formatters"; + +export interface MedicationEnrichmentViewModel { + query: string; + results: MedicationEnrichmentSearchResult[]; + hasMoreResults?: boolean; + isSearching: boolean; + hasSearched: boolean; + searchError: string | null; + applyingCode: string | null; + activeResultCode: string | null; + appliedSelection: MedicationEnrichmentEnrichResponse["selection"] | null; + enrichError: string | null; + meta: MedicationEnrichmentEnrichResponse["meta"] | null; + strengthOptions: MedicationEnrichmentStrengthOption[]; + appliedStrengthLabel: string | null; +} + +export interface MedicationEnrichmentSectionProps { + state: MedicationEnrichmentViewModel; + onQueryChange: (value: string) => void; + onSearch: () => void; + onLoadMoreResults?: () => void; + onApplyResult: (result: MedicationEnrichmentSearchResult) => void; + onApplyStrength: (option: MedicationEnrichmentStrengthOption) => void; +} + +export function MedicationEnrichmentSection({ + state, + onQueryChange, + onSearch, + onLoadMoreResults, + onApplyResult, + onApplyStrength, +}: MedicationEnrichmentSectionProps) { + const { t } = useTranslation(); + const canSearch = state.query.trim().length > 0 && !state.isSearching && !state.applyingCode; + const shouldAutoExpand = + state.isSearching || + state.hasSearched || + state.searchError !== null || + state.enrichError !== null || + state.results.length > 0 || + state.appliedSelection !== null || + state.strengthOptions.length > 0 || + state.appliedStrengthLabel !== null || + Boolean(state.meta?.partial); + const [isExpanded, setIsExpanded] = useState(shouldAutoExpand); + const [showInfo, setShowInfo] = useState(false); + const [expandedResultCode, setExpandedResultCode] = useState(null); + const autoExpandStateRef = useRef(shouldAutoExpand); + + useEffect(() => { + if (shouldAutoExpand && !autoExpandStateRef.current) { + setIsExpanded(true); + } + + autoExpandStateRef.current = shouldAutoExpand; + }, [shouldAutoExpand]); + + return ( +
+
+
+
{t("form.enrichment.title")}
+

{t("form.enrichment.collapsedHint")}

+
+ +
+ + {isExpanded ? ( +
+
+ {t("form.enrichment.coverageLabel")} + +
+ + {showInfo ? ( +
+

{t("form.enrichment.infoTitle")}

+

{t("form.enrichment.description")}

+

{t("form.enrichment.manualEntryHint")}

+
+ ) : null} + + + + {state.searchError ?

{state.searchError}

: null} + {state.enrichError ?

{state.enrichError}

: null} + {state.meta?.partial ?

{t("form.enrichment.partialNote")}

: null} + {state.hasSearched && !state.isSearching && state.results.length === 0 ? ( +

{t("form.enrichment.noResults")}

+ ) : null} + + {state.results.length > 0 ? ( +
+ {state.results.map((result) => { + const isActive = state.activeResultCode === result.code; + const hasDetails = Boolean( + result.authorisationHolder || result.therapeuticArea || result.authorisationDate + ); + const isDetailsExpanded = expandedResultCode === result.code; + const genericStatusClass = result.genericStatus === "generic" ? "success" : "neutral"; + const sourceClass = result.source === "openfda" ? "warning" : "neutral"; + let applyLabel = t("form.enrichment.applyAction"); + if (state.applyingCode === result.code) { + applyLabel = t("form.enrichment.applying"); + } else if (isActive && state.appliedSelection) { + applyLabel = t("form.enrichment.applied"); + } + + return ( +
+
+
+ {result.name} + {result.genericName ? ( + {result.genericName} + ) : null} +
+
+ {t(`form.enrichment.sources.${result.source}`)} + {result.source === "ema" ? ( + + {t(`form.enrichment.genericStatus.${result.genericStatus}`)} + + ) : null} + {hasDetails ? ( + + ) : null} + +
+
+ + {hasDetails && isDetailsExpanded ? ( +
+ {result.authorisationHolder ? ( +
+
{t("form.enrichment.details.authorisationHolder")}
+
{result.authorisationHolder}
+
+ ) : null} + {result.therapeuticArea ? ( +
+
{t("form.enrichment.details.therapeuticArea")}
+
{result.therapeuticArea}
+
+ ) : null} + {result.authorisationDate ? ( +
+
{t("form.enrichment.details.authorisationDate")}
+
{formatDate(result.authorisationDate)}
+
+ ) : null} +
+ ) : null} +
+ ); + })} +
+ ) : null} + + {state.results.length > 0 && state.hasMoreResults && onLoadMoreResults ? ( +
+ +
+ ) : null} + + {state.appliedSelection || state.strengthOptions.length > 0 || state.appliedStrengthLabel ? ( +
+ {state.appliedSelection ? ( +
+

{t("form.enrichment.applied")}

+

+ {state.appliedSelection.name} + {state.appliedSelection.genericName ? ` • ${state.appliedSelection.genericName}` : ""} +

+
+ ) : null} + + {state.strengthOptions.length > 0 ? ( +
+

{t("form.enrichment.strengthTitle")}

+

{t("form.enrichment.strengthHint")}

+
+ {state.strengthOptions.map((option) => { + const isSelected = state.appliedStrengthLabel === option.label; + + return ( + + ); + })} +
+
+ ) : null} + + {state.appliedStrengthLabel ? ( +

+ {t("form.enrichment.appliedStrength", { label: state.appliedStrengthLabel })} +

+ ) : null} +
+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/frontend/src/components/MobileEditModal.tsx b/frontend/src/components/MobileEditModal.tsx index a747379..4ce4ed1 100644 --- a/frontend/src/components/MobileEditModal.tsx +++ b/frontend/src/components/MobileEditModal.tsx @@ -9,7 +9,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useEscapeKey } from "../hooks/useEscapeKey"; import { useScrollLock } from "../hooks/useScrollLock"; -import type { DoseUnit, FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types"; +import type { + DoseUnit, + FieldErrors, + FormBlister, + FormIntake, + FormState, + Medication, + MedicationEnrichmentSearchResult, + MedicationEnrichmentStrengthOption, +} from "../types"; import { allowsPillFormSelection, DOSE_UNITS, @@ -28,6 +37,8 @@ import { } from "../utils/intake-schedule"; import { DateInput } from "./DateInput"; import { FormNumberStepper } from "./FormNumberStepper"; +import type { MedicationEnrichmentViewModel } from "./MedicationEnrichmentSection"; +import { MedicationEnrichmentSection } from "./MedicationEnrichmentSection"; // Field limits for validation const FIELD_LIMITS = { @@ -40,11 +51,33 @@ const FIELD_LIMITS = { const MOBILE_TAB_ORDER = ["general", "stock", "schedule", "prescription"] as const; type MobileTab = (typeof MOBILE_TAB_ORDER)[number]; +const EMPTY_MEDICATION_ENRICHMENT: MedicationEnrichmentViewModel = { + query: "", + results: [], + hasMoreResults: false, + isSearching: false, + hasSearched: false, + searchError: null, + applyingCode: null, + activeResultCode: null, + appliedSelection: null, + enrichError: null, + meta: null, + strengthOptions: [], + appliedStrengthLabel: null, +}; + export interface MobileEditModalProps { show: boolean; editingId: number | null; form: FormState; onFormChange: (form: FormState) => void; + medicationEnrichment?: MedicationEnrichmentViewModel; + onMedicationEnrichmentQueryChange?: (value: string) => void; + onMedicationEnrichmentSearch?: () => void; + onMedicationEnrichmentLoadMore?: () => void; + onMedicationEnrichmentApply?: (result: MedicationEnrichmentSearchResult) => void; + onMedicationEnrichmentStrengthApply?: (option: MedicationEnrichmentStrengthOption) => void; fieldErrors: FieldErrors; saving: boolean; formSaved: boolean; @@ -97,6 +130,12 @@ export function MobileEditModal({ editingId, form, onFormChange, + medicationEnrichment = EMPTY_MEDICATION_ENRICHMENT, + onMedicationEnrichmentQueryChange = () => {}, + onMedicationEnrichmentSearch = () => {}, + onMedicationEnrichmentLoadMore = () => {}, + onMedicationEnrichmentApply = () => {}, + onMedicationEnrichmentStrengthApply = () => {}, fieldErrors, saving, formSaved, @@ -446,6 +485,14 @@ export function MobileEditModal({ {fieldErrors.genericName} )} +
+