diff --git a/src/app/api/medications/[id]/titration/__tests__/route.test.ts b/src/app/api/medications/[id]/titration/__tests__/route.test.ts new file mode 100644 index 000000000..f505fb912 --- /dev/null +++ b/src/app/api/medications/[id]/titration/__tests__/route.test.ts @@ -0,0 +1,227 @@ +/** + * v1.4.25 W19f — titration-ladder route tests. + * + * Mirrors the W19e cadence-route test fixture: external dependencies + * are mocked at module boundaries (prisma, session, logging + * transport) so each test pins one contract case. The pure ladder + * helpers are covered separately in + * `src/lib/medications/titration/__tests__/ladder.test.ts`. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + medication: { findUnique: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "marc", role: "USER" as const }, +}; + +function makeMed( + overrides: Partial<{ + name: string; + dose: string; + treatmentClass: "GLP1" | "GENERIC"; + userId: string; + doseChanges: Array<{ effectiveFrom: Date; doseValue: number }>; + }> = {}, +) { + return { + id: "med-1", + name: overrides.name ?? "Mounjaro", + dose: overrides.dose ?? "5 mg", + treatmentClass: overrides.treatmentClass ?? ("GLP1" as const), + userId: overrides.userId ?? "user-1", + createdAt: new Date(), + doseChanges: overrides.doseChanges ?? [], + }; +} + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("GET /api/medications/[id]/titration", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await GET( + new NextRequest("http://localhost/api/medications/med-1/titration"), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(401); + }); + + it("returns 404 when the medication is not owned by the caller", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue( + makeMed({ userId: "OTHER" }) as never, + ); + + const res = await GET( + new NextRequest("http://localhost/api/medications/med-1/titration"), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(404); + }); + + it("returns 404 when the medication is not GLP-1", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue( + makeMed({ treatmentClass: "GENERIC" }) as never, + ); + + const res = await GET( + new NextRequest("http://localhost/api/medications/med-1/titration"), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(404); + }); + + it("returns 404 when the medication brand is not in the GLP-1 catalog", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue( + makeMed({ name: "MysteryDrug" }) as never, + ); + + const res = await GET( + new NextRequest("http://localhost/api/medications/med-1/titration"), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(404); + }); + + it("returns the ladder + current step for a Mounjaro user on 5 mg", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue( + makeMed({ + name: "Mounjaro", + dose: "5 mg", + doseChanges: [ + { + effectiveFrom: new Date(Date.now() - 6 * 7 * 24 * 60 * 60 * 1000), + doseValue: 2.5, + }, + { + effectiveFrom: new Date(Date.now() - 2 * 7 * 24 * 60 * 60 * 1000), + doseValue: 5, + }, + ], + }) as never, + ); + + const res = await GET( + new NextRequest("http://localhost/api/medications/med-1/titration"), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data.drugId).toBe("tirzepatide"); + expect(json.data.ladder.length).toBe(6); + expect(json.data.currentStep.doseMg).toBe(5); + expect(json.data.currentStepIndex).toBe(1); + expect(json.data.weeksOnCurrentStep).toBe(2); + expect(json.data.nextStep.doseMg).toBe(7.5); + expect(json.data.escalationDue).toBe(false); + }); + + it("returns a null current step when the user dose is outside any ladder bucket", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue( + makeMed({ + name: "Mounjaro", + dose: "6 mg", // outside the ±10 % window of 5 and 7.5 + doseChanges: [], + }) as never, + ); + + const res = await GET( + new NextRequest("http://localhost/api/medications/med-1/titration"), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data.currentStep).toBeNull(); + expect(json.data.currentStepIndex).toBeNull(); + expect(json.data.nextStep).toBeNull(); + expect(json.data.escalationDue).toBe(false); + }); + + it("returns a null next-step at the ladder ceiling", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue( + makeMed({ + name: "Mounjaro", + dose: "15 mg", + doseChanges: [ + { + effectiveFrom: new Date(Date.now() - 5 * 7 * 24 * 60 * 60 * 1000), + doseValue: 15, + }, + ], + }) as never, + ); + + const res = await GET( + new NextRequest("http://localhost/api/medications/med-1/titration"), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data.currentStep.doseMg).toBe(15); + expect(json.data.nextStep).toBeNull(); + expect(json.data.escalationDue).toBe(false); + }); + + it("flags escalationDue=true past the EMA dwell-time on a non-ceiling step", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue( + makeMed({ + name: "Mounjaro", + dose: "2.5 mg", + doseChanges: [ + { + // Six weeks ago, past tirzepatide's 4-week interval. + effectiveFrom: new Date(Date.now() - 6 * 7 * 24 * 60 * 60 * 1000), + doseValue: 2.5, + }, + ], + }) as never, + ); + + const res = await GET( + new NextRequest("http://localhost/api/medications/med-1/titration"), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data.currentStep.doseMg).toBe(2.5); + expect(json.data.weeksOnCurrentStep).toBe(6); + expect(json.data.escalationDue).toBe(true); + }); +}); diff --git a/src/app/api/medications/[id]/titration/route.ts b/src/app/api/medications/[id]/titration/route.ts new file mode 100644 index 000000000..e0cab8a41 --- /dev/null +++ b/src/app/api/medications/[id]/titration/route.ts @@ -0,0 +1,146 @@ +/** + * v1.4.25 W19f — GLP-1 titration-ladder read endpoint. + * + * GET /api/medications/[id]/titration + * - returns the EMA-reference ladder for the medication's drug, + * the user's current step (matched within ±10 % tolerance to + * their latest dose), how long they've been on that step, the + * immediate-next step (if any), and the observational + * escalation-due flag. + * + * Pure computation — no writes. Delegates math to the + * `src/lib/medications/titration/ladder` module so the route, the + * Coach snapshot (future wave), and the detail-page section all read + * the same numbers. + * + * Auth: cookie-session via requireAuth(); medication is verified to + * belong to the caller before any read. Defence-in-depth: returns + * 404 for non-GLP-1 medications (the UI already gates on + * `treatmentClass === "GLP1"`). + */ + +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { findDrugByBrand, GLP1_DRUGS, type Glp1DrugId } from "@/lib/medications/glp1-knowledge"; +import { + escalationDue, + findCurrentStep, + ladderFromRecord, + nextStep, + weeksOnCurrentStep, +} from "@/lib/medications/titration/ladder"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const med = await prisma.medication.findUnique({ + where: { id }, + include: { + doseChanges: { orderBy: { effectiveFrom: "asc" } }, + }, + }); + if (!med || med.userId !== user.id) { + return apiError("Medication not found", 404); + } + if (med.treatmentClass !== "GLP1") { + // Defence-in-depth: the UI already gates the section on this + // class, but the route refuses to surface the ladder for a + // non-GLP-1 row so we never leak a misleading "you are here" + // on a generic medication. + return apiError("Medication not found", 404); + } + + // Resolve the GLP-1 drug record from the medication name. Same + // path the DrugLevelChart uses; null when the medication name + // doesn't map to a catalog brand (e.g. user typed a generic + // INN instead of a brand). + const record = findDrugByBrand(med.name); + if (!record) { + return apiError("Medication not found", 404); + } + let drugId: Glp1DrugId | null = null; + for (const [id, r] of Object.entries(GLP1_DRUGS)) { + if (r === record) { + drugId = id as Glp1DrugId; + break; + } + } + if (!drugId) { + return apiError("Medication not found", 404); + } + + const ladder = ladderFromRecord(record); + + // Resolve the user's most-recent dose. Prefer the + // MedicationDoseChange stream (the history of record); fall back + // to the legacy free-text `medication.dose` if the stream is empty. + const latestChange = + med.doseChanges.length > 0 + ? med.doseChanges[med.doseChanges.length - 1] + : null; + const latestDoseMg = latestChange + ? latestChange.doseValue + : parseDoseString(med.dose); + + const currentStep = findCurrentStep(drugId, latestDoseMg); + const next = nextStep(drugId, currentStep); + const asOf = new Date(); + const weeks = weeksOnCurrentStep( + drugId, + currentStep, + med.doseChanges.map((dc) => ({ + effectiveFrom: dc.effectiveFrom, + doseValue: dc.doseValue, + })), + asOf, + ); + const due = escalationDue(drugId, currentStep, weeks); + + annotate({ + action: { + name: "medication.titration", + entity_type: "medication", + entity_id: id, + }, + meta: { + drug_id: drugId, + step_index: currentStep?.stepIndex ?? null, + weeks_on_step: weeks, + }, + }); + + return apiSuccess({ + drugId, + drugInn: record.inn, + ladder, + currentStep, + currentStepIndex: currentStep?.stepIndex ?? null, + weeksOnCurrentStep: weeks, + nextStep: next, + escalationDue: due, + sourceEMA: record.sourceEMA, + }); + }, +); + +/** + * Parse the legacy free-text `medication.dose` ("0.5 mg", "5 mg", + * "0.25") into a numeric mg value. Returns null when the string + * doesn't carry a parseable number — the helper above treats null + * as "no current step" rather than 0. + */ +function parseDoseString(dose: string): number | null { + const match = dose.match(/[-+]?\d*\.?\d+/); + if (!match) return null; + const n = Number(match[0]); + if (!Number.isFinite(n)) return null; + return n; +}