mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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;
|
||||
}
|
||||
Reference in new issue
Block a user