From 4cd49e9fa2ecba9ac859a9e2cc682bbf54a8a0ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 8 Jun 2026 18:08:03 +0200 Subject: [PATCH] feat(medications): add the dose-history read endpoint for the history view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/medications/[id]/dose-history?from=&to= returns the full per-slot ledger over the window: every expected slot with a status (taken on-time / late / skipped / missed / upcoming) plus every off-schedule intake tagged ad-hoc, built from the same band minter the compliance % consumes so the history view and the rate can never disagree. Ownership-scoped, rate-limited, reads only non-tombstoned rows, instants serialised as ISO strings. Additive — iOS-safe. This is what the medication Verlauf tab renders. --- .../[id]/dose-history/__tests__/route.test.ts | 211 ++++++++++++++ .../medications/[id]/dose-history/route.ts | 266 ++++++++++++++++++ 2 files changed, 477 insertions(+) create mode 100644 src/app/api/medications/[id]/dose-history/__tests__/route.test.ts create mode 100644 src/app/api/medications/[id]/dose-history/route.ts diff --git a/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts b/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts new file mode 100644 index 000000000..967a62714 --- /dev/null +++ b/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts @@ -0,0 +1,211 @@ +/** + * v1.15.18 — GET /api/medications/[id]/dose-history. + * + * The endpoint the medication "Verlauf" tab consumes. Pins the contract: + * - ownership-scoped (404 from the shared helper) + rate-limited (429); + * - reads only `deletedAt: null` rows; + * - returns the unified dose-history ledger (every expected slot with a + * status + ad-hoc takes), built from the SAME bands the compliance % uses, + * so the history view can never disagree with the rate; + * - serialises instants as ISO strings (iOS-safe additive shape). + * + * The band attribution itself is covered exhaustively by the pure-engine + * suites (`band-minter` / `attribution` / `dose-history` / `attribute-intake`); + * this file pins the route's wiring + envelope. + */ +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/medications/route-guards", () => ({ + assertMedicationOwnership: vi.fn().mockResolvedValue(null), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 59, + resetAt: Date.now() + 60_000, + }), + rateLimitHeaders: vi.fn(() => ({ "X-RateLimit-Remaining": "0" })), +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +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"; +import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { localHmAsUtc } from "@/lib/timezone"; + +const TZ = "Europe/Berlin"; +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, + timezone: TZ, + }, +}; + +const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) }; + +function getReq(query = ""): NextRequest { + return new NextRequest( + `http://localhost/api/medications/med-1/dose-history${query}`, + { method: "GET" }, + ); +} + +function at(dayRef: Date, h: number, m: number): Date { + return localHmAsUtc(dayRef, TZ, h, m); +} + +const DAY = new Date("2026-06-05T12:00:00Z"); // a couple days in the past + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(assertMedicationOwnership).mockResolvedValue(null); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 59, + resetAt: Date.now() + 60_000, + }); + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00Z"), + schedules: [ + { + id: "sched-1", + windowStart: "07:00", + windowEnd: "07:00", + daysOfWeek: null, + timesOfDay: ["07:00", "19:00"], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }, + ], + } as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never); +}); + +describe("GET /api/medications/[id]/dose-history", () => { + it("returns the 404 from the shared ownership helper", async () => { + vi.mocked(assertMedicationOwnership).mockResolvedValueOnce( + new Response(null, { status: 404 }) as never, + ); + const res = await GET(getReq(), ROUTE_PARAMS); + expect(res.status).toBe(404); + }); + + it("returns 429 when the per-user cap is exhausted", async () => { + vi.mocked(checkRateLimit).mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + }); + const res = await GET(getReq(), ROUTE_PARAMS); + expect(res.status).toBe(429); + }); + + it("reads only deletedAt:null rows", async () => { + await GET(getReq(), ROUTE_PARAMS); + const where = vi.mocked(prisma.medicationIntakeEvent.findMany).mock + .calls[0][0]?.where; + expect(where).toMatchObject({ + medicationId: "med-1", + userId: "user-1", + deletedAt: null, + }); + }); + + it("attributes an on-time take to its slot and orphans an off-window take", async () => { + const from = at(DAY, 0, 0).toISOString(); + const to = at(DAY, 23, 59).toISOString(); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([ + // on-time morning take + { + id: "evt-morning", + scheduledFor: at(DAY, 7, 0), + takenAt: at(DAY, 7, 5), + skipped: false, + autoMissed: false, + }, + // off-window midday take (was the ±6h-snapped "07:00 dose") + { + id: "evt-adhoc", + scheduledFor: at(DAY, 11, 29), + takenAt: at(DAY, 11, 29), + skipped: false, + autoMissed: false, + }, + ] as never); + + const res = await GET( + getReq(`?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`), + ROUTE_PARAMS, + ); + expect(res.status).toBe(200); + const json = await res.json(); + const rows = json.data.rows as Array<{ + kind: string; + timeOfDay: string | null; + status: string; + intake: { id: string | null } | null; + }>; + + // 07:00 slot taken on-time; 19:00 slot missed (it is in the past); the + // midday take is ad-hoc, NOT snapped onto a slot. + const morning = rows.find((r) => r.timeOfDay === "07:00"); + expect(morning?.status).toBe("taken_on_time"); + expect(morning?.intake?.id).toBe("evt-morning"); + + const evening = rows.find((r) => r.timeOfDay === "19:00"); + expect(evening?.status).toBe("missed"); + + const adHoc = rows.find((r) => r.kind === "ad_hoc"); + expect(adHoc?.status).toBe("ad_hoc"); + expect(adHoc?.intake?.id).toBe("evt-adhoc"); + }); + + it("422 on a reversed from/to window", async () => { + const from = at(DAY, 12, 0).toISOString(); + const to = at(DAY, 6, 0).toISOString(); + const res = await GET( + getReq(`?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`), + ROUTE_PARAMS, + ); + expect(res.status).toBe(422); + }); +}); diff --git a/src/app/api/medications/[id]/dose-history/route.ts b/src/app/api/medications/[id]/dose-history/route.ts new file mode 100644 index 000000000..9045525ea --- /dev/null +++ b/src/app/api/medications/[id]/dose-history/route.ts @@ -0,0 +1,266 @@ +/** + * v1.15.18 — traceable dose-history read endpoint (spec B; audit deliverable). + * + * GET /api/medications/[id]/dose-history?from=&to= + * → the full per-slot ledger for the medication over [from, to]: every + * expected slot with a status (taken on-time / taken late / skipped / + * missed / upcoming) plus every off-schedule intake tagged ad-hoc. + * + * This is the read-model the medication "Verlauf" tab renders. It is built + * from the SAME shared band minter + `reconstructDoseHistory` that the + * compliance % and the write/edit attribution consume, so the history view and + * the rate can never contradict each other. Additive (a new GET) → iOS-safe. + * + * Ownership-scoped via `assertMedicationOwnership`; rate-limited; reads only + * `deletedAt: null` rows. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { + apiError, + apiSuccess, + returnAllZodIssues, +} from "@/lib/api-response"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { lastNonSkippedTakenAt } from "@/lib/analytics/compliance"; +import type { SlotBand } from "@/lib/medications/scheduling/attribution"; +import { + buildBandsForSchedules, + type BandFamily, + type BandMinterMedication, +} from "@/lib/medications/scheduling/band-minter"; +import { + reconstructDoseHistory, + type DoseHistoryRow, + type HistoryIntake, +} from "@/lib/medications/scheduling/dose-history"; +import { + buildCanonicalSchedule, + buildRecurrenceContext, + type WorkerScheduleRow, +} from "@/lib/medications/scheduling/worker-helpers"; +import { assertMedicationOwnership } from "@/lib/medications/route-guards"; + +type RouteParams = { params: Promise<{ id: string }> }; + +const DAY_MS = 24 * 60 * 60 * 1000; +const MAX_WINDOW_DAYS = 366; + +// Match the glp1 POST / bulk-delete cap so a caller can't pin the per-request +// band expansion. 60/min/user is generous for a hand-driven history view. +const RATE_LIMIT = 60; +const RATE_WINDOW_MS = 60_000; + +const querySchema = z + .object({ + from: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .optional(), + to: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .optional(), + }) + .refine((q) => !q.from || !q.to || q.to.getTime() >= q.from.getTime(), { + message: "`to` must be on or after `from`", + path: ["to"], + }); + +/** The serialised shape the Verlauf tab consumes (instants as ISO strings). */ +interface SerializedDoseHistoryRow { + kind: "slot" | "ad_hoc"; + at: string; + timeOfDay: string | null; + status: DoseHistoryRow["status"]; + intake: { + id: string | null; + scheduledFor: string; + takenAt: string | null; + skipped: boolean; + autoMissed: boolean; + } | null; +} + +export const GET = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const { id } = await params; + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; + + const rl = await checkRateLimit( + `medication-dose-history:${user.id}`, + RATE_LIMIT, + RATE_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many requests", 429, { + headers: rateLimitHeaders(rl), + }); + } + + const parsed = querySchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams), + ); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422); + } + + const now = new Date(); + const userTz = user.timezone || "Europe/Berlin"; + + const medication = await prisma.medication.findUnique({ + where: { id }, + include: { schedules: true }, + }); + if (!medication) { + return apiError("Medication not found", 404); + } + + // Resolve the window. Default to the trailing 90 days; clamp the floor to + // the medication's creation so pre-existence days never mint phantom + // slots, and cap the span so a pathological `from=1970` can't expand a + // year-plus of bands. `to` defaults to `now`. + const to = parsed.data.to ?? now; + const requestedFrom = + parsed.data.from ?? new Date(to.getTime() - 90 * DAY_MS); + const spanFloor = new Date(to.getTime() - MAX_WINDOW_DAYS * DAY_MS); + const from = new Date( + Math.max( + requestedFrom.getTime(), + medication.createdAt.getTime(), + spanFloor.getTime(), + ), + ); + + const events = await prisma.medicationIntakeEvent.findMany({ + where: { medicationId: id, userId: user.id, deletedAt: null }, + orderBy: { scheduledFor: "desc" }, + select: { + id: true, + scheduledFor: true, + takenAt: true, + skipped: true, + autoMissed: true, + }, + }); + + const mapped = events.map((e) => ({ + id: e.id, + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + autoMissed: e.autoMissed, + })); + + const lastIntakeAt = lastNonSkippedTakenAt(mapped); + + const bandMedication: BandMinterMedication = { + id: medication.id, + startsOn: medication.startsOn, + endsOn: medication.endsOn, + oneShot: medication.oneShot, + createdAt: medication.createdAt, + }; + const ctx = buildRecurrenceContext({ + medication: bandMedication, + userTz, + lastIntakeAt, + }); + // A legacy daily schedule carrying only `windowStart` surfaces it as the + // single time-of-day so the minter mints its daily band (mirrors the + // compliance route + the ledger tally). + const canonicalSchedules = medication.schedules.map((s) => { + const canonical = buildCanonicalSchedule(s as WorkerScheduleRow); + if ( + canonical.timesOfDay.length === 0 && + canonical.rrule === null && + canonical.rollingIntervalDays === null && + canonical.scheduleType !== "PRN" && + !medication.oneShot + ) { + return { ...canonical, timesOfDay: [canonical.windowStart] }; + } + return canonical; + }); + + // Rolling cadences anchor their retrospective grid AT each logged intake. + const intakeInstants = mapped + .filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= to) + .map((e) => e.takenAt as Date) + .sort((a, b) => a.getTime() - b.getTime()); + + const groups = buildBandsForSchedules({ + medication: bandMedication, + schedules: canonicalSchedules, + ctx, + userTz, + range: { from, to }, + now, + intakeInstants, + }); + const bands: SlotBand[] = []; + let family: BandFamily = "none"; + for (const g of groups) { + if (g.hasExpectedSlots) { + bands.push(...g.bands); + if (family === "none") family = g.family; + } + } + + // Intakes whose slot anchor falls in the window. An ad-hoc take buckets on + // its real time, but the ledger reads `scheduledFor` for the membership + // partition, so filter on the stored anchor (a take snapped to an + // out-of-window slot is excluded, matching the compliance read). + const historyIntakes: HistoryIntake[] = mapped + .filter((e) => e.scheduledFor >= from && e.scheduledFor <= to) + .map((e) => ({ + id: e.id, + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + autoMissed: e.autoMissed, + })); + + const rows = reconstructDoseHistory(bands, historyIntakes, now); + + const serialized: SerializedDoseHistoryRow[] = rows.map((row) => ({ + kind: row.kind, + at: row.at.toISOString(), + timeOfDay: row.timeOfDay, + status: row.status, + intake: row.intake + ? { + id: row.intake.id ?? null, + scheduledFor: row.intake.scheduledFor.toISOString(), + takenAt: row.intake.takenAt?.toISOString() ?? null, + skipped: row.intake.skipped, + autoMissed: row.intake.autoMissed ?? false, + } + : null, + })); + + annotate({ + action: { + name: "medication.dose_history", + entity_type: "medication", + entity_id: id, + }, + meta: { row_count: serialized.length, family }, + }); + + return apiSuccess({ + from: from.toISOString(), + to: to.toISOString(), + family, + hasExpectedSlots: bands.length > 0, + rows: serialized, + }); + }, +);