feat(medications): cadence + compliance read endpoint

This commit is contained in:
Marc-André Bombeck committed 2026-05-14 21:11:54 +02:00
1 parent 4af09170bb
commit 3ef49679ed
2 files changed
+296

No files matched your search

@@ -0,0 +1,168 @@
/**
* v1.4.25 W19e — cadence + compliance API route tests.
*
* Mirrors the W19d side-effect test fixture pattern: external
* dependencies are mocked at module boundaries (Prisma, session,
* logging transport), and each test pins one contract case.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findUnique: vi.fn() },
medicationIntakeEvent: { findMany: 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 },
};
beforeEach(() => {
vi.resetAllMocks();
});
describe("GET /api/medications/[id]/cadence", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/cadence"),
{ 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({
id: "med-1",
userId: "OTHER",
createdAt: new Date(),
schedules: [],
} as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/cadence"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(404);
});
it("returns 422 for an out-of-range days parameter", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
createdAt: new Date(),
schedules: [],
} as never);
const res = await GET(
new NextRequest(
"http://localhost/api/medications/med-1/cadence?days=9999",
),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("returns the timeline + chips for an owned medication", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
createdAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
schedules: [
{
id: "sch-1",
medicationId: "med-1",
windowStart: "08:00",
windowEnd: "09:00",
label: null,
dose: null,
daysOfWeek: null,
},
],
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([]);
const res = await GET(
new NextRequest(
"http://localhost/api/medications/med-1/cadence?days=14",
),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.windowDays).toBe(14);
expect(Array.isArray(json.data.timeline)).toBe(true);
expect(json.data.chips).toMatchObject({
adherenceRate: expect.anything(),
currentStreak: expect.any(Number),
longestStreak: expect.any(Number),
missedLast30: expect.any(Number),
});
});
it("defaults to a 30-day window when no `days` query param is supplied", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
createdAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
schedules: [],
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([]);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/cadence"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.windowDays).toBe(30);
});
it("returns a `null` next-dose when no schedule expands in the lookahead", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
createdAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
schedules: [],
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([]);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/cadence"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.next).toBeNull();
});
});
@@ -0,0 +1,128 @@
/**
* v1.4.25 W19e — GLP-1 cadence + compliance read endpoint.
*
* GET /api/medications/[id]/cadence?days=30
* - returns the schedule's 30-day (configurable) expected-vs-actual
* dose timeline plus the four compliance chip values that drive
* the detail-page section.
*
* Pure computation — no writes. The route delegates math to the
* `src/lib/medications/scheduling/{cadence,compliance}` pure modules
* so the chart, the chips, and the server agree dose-for-dose.
*
* Auth: cookie-session via requireAuth(); the medication is verified
* to belong to the caller before any read.
*/
import { NextRequest } from "next/server";
import { z } from "zod";
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 {
buildCadenceTimeline,
computeNextDose,
} from "@/lib/medications/scheduling/cadence";
import { complianceChips } from "@/lib/medications/scheduling/compliance";
type RouteParams = { params: Promise<{ id: string }> };
const querySchema = z.object({
days: z.coerce.number().int().min(1).max(180).optional(),
});
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: { schedules: true },
});
if (!med || med.userId !== user.id) {
return apiError("Medication not found", 404);
}
const url = new URL(request.url);
const parsed = querySchema.safeParse({
days: url.searchParams.get("days") ?? undefined,
});
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const windowDays = parsed.data.days ?? 30;
const asOf = new Date();
// Pull intake events covering the window + a small buffer so a dose
// logged at the edge can still pair with its slot.
const bufferMs = 24 * 60 * 60 * 1000;
const from = new Date(asOf.getTime() - windowDays * 24 * 60 * 60 * 1000 - bufferMs);
const events = await prisma.medicationIntakeEvent.findMany({
where: {
medicationId: id,
userId: user.id,
scheduledFor: { gte: from },
},
select: { scheduledFor: true, takenAt: true, skipped: true },
orderBy: { scheduledFor: "asc" },
});
// The cadence-anchor is the medication's createdAt for stable
// intervalWeeks=N phasing. Falling back to `from` would re-anchor
// every window-size change, which would make a 30-day vs 90-day
// chart disagree on the bi-weekly grid.
const anchor = med.createdAt;
const timeline = buildCadenceTimeline(
med.schedules,
events,
asOf,
windowDays,
anchor,
);
const chips = complianceChips(
med.schedules,
events,
asOf,
windowDays,
anchor,
);
const next = computeNextDose(med.schedules, asOf, 14, anchor);
annotate({
action: {
name: "medication.cadence",
entity_type: "medication",
entity_id: id,
},
meta: {
window_days: windowDays,
slots: timeline.length,
adherence: chips.adherenceRate,
},
});
return apiSuccess({
windowDays,
anchorIso: anchor.toISOString(),
next: next
? {
windowStart: next.windowStart.toISOString(),
windowEnd: next.windowEnd.toISOString(),
scheduleIndex: next.scheduleIndex,
}
: null,
chips,
timeline: timeline.map((p) => ({
day: p.day.toISOString(),
windowStart: p.windowStart.toISOString(),
windowEnd: p.windowEnd.toISOString(),
scheduleIndex: p.scheduleIndex,
status: p.status,
})),
});
},
);