feat(medications): add the dose-history read endpoint for the history view

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-08 18:08:03 +02:00
1 parent 9532033277
commit 4cd49e9fa2
2 files changed
+477

No files matched your search

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