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>
This commit is contained in:
Daniel Volz
2026-03-20 20:39:38 +01:00
committed by GitHub
parent e1b47e82b2
commit b796e03bcb
16 changed files with 3510 additions and 2 deletions
+12
View File
@@ -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),
+223
View File
@@ -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);
}
}
);
}
File diff suppressed because it is too large Load Diff
@@ -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<string, unknown>> = {}): Record<string, unknown> {
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<FastifyInstance> {
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();
});
});