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