feat(api): GET titration-ladder + current-step for GLP-1 medications

Returns the EMA-reference ladder for the medication's drug, the
user's matched current step (snapped within ±10 % of their latest
dose), elapsed whole-weeks on that step, the immediate-next step,
and the observational escalation-due flag.

  GET /api/medications/[id]/titration
    -> { drugId, drugInn, ladder, currentStep, currentStepIndex,
         weeksOnCurrentStep, nextStep, escalationDue, sourceEMA }

Defence-in-depth gating:
  - requireAuth() + medication ownership check (404 on mismatch);
  - 404 when treatmentClass !== 'GLP1' (the UI already gates here);
  - 404 when the medication name doesn't map to a catalog brand.

Reads the user's latest dose from the MedicationDoseChange stream
(history of record); falls back to parsing the legacy free-text
medication.dose only when the stream is empty.

Pure-read; no writes; annotate() instrumentation only. Same shape
as the W19e cadence route — math lives in the pure helpers, prisma
lives in the route.

8 cases pass covering 401, 404 paths (non-owner, non-GLP-1,
unknown brand), the happy path on a 5 mg Mounjaro user, the
non-standard-dose null path, the ladder-ceiling no-next-step path,
and the escalationDue=true boundary past EMA dwell-time.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 21:23:14 +02:00
1 parent bdaf128931
commit b4ed2eda9e
2 files changed
+373

No files matched your search

@@ -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);
});
});
@@ -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;
}