feat(medications): add status filter to GET /api/medications/[id]/intake

Extend the list endpoint with an optional `status` query knob —
`all` (default), `taken`, `skipped`, `completed` — so the detail-page
intake history can hide unconfirmed rows server-side without changing
the byte-stable contract the iOS Swift client and the existing
dashboard tiles depend on. `completed` (taken OR skipped) covers the
detail-page surface; `taken` / `skipped` are there for any future
single-arm consumer. Co-located route test pins the Prisma where
fragment per branch and the 422 on unknown values.
This commit is contained in:
Marc-André Bombeck committed 2026-05-17 14:13:20 +02:00
1 parent 79a1b4fbc6
commit a0af8c7208
3 files changed
+207 -3

No files matched your search

@@ -0,0 +1,163 @@
/**
* v1.4.37 W3 — GET /api/medications/[id]/intake `status` filter tests.
*
* Pins the contract added to fix the v1.4.36 regression where
* `IntakeHistoryListV2` rendered rows with `takenAt:null AND
* skipped:false` as "Eingenommen". The route now accepts an optional
* `status` query param:
*
* - default (`status:"all"`) keeps the byte-stable contract for the
* iOS Swift client and the dashboard tiles already on the wire.
* - `status:"completed"` — taken OR skipped — is what the
* detail-page list passes so ambiguous "missed / unconfirmed"
* rows stay out of the user-facing table.
* - `status:"taken"` / `status:"skipped"` cover the two single-arm
* branches future surfaces may need.
*
* The route-level guard (404 on cross-user reads) is exercised by
* the shared `assertMedicationOwnership` test suite — this file only
* cares about the Prisma `where` fragment derived from the new knob.
*/
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(),
count: vi.fn(),
},
},
}));
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";
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 },
};
const MED_OK = { id: "med-1", userId: "user-1" };
function makeRequest(query: string): NextRequest {
return new NextRequest(
`http://localhost/api/medications/med-1/intake?${query}`,
);
}
const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0);
});
describe("GET /api/medications/[id]/intake — status filter", () => {
it("applies no status filter when `status` is omitted (back-compat)", async () => {
const res = await GET(makeRequest("limit=25&offset=0"), ROUTE_PARAMS);
expect(res.status).toBe(200);
const where = vi.mocked(prisma.medicationIntakeEvent.findMany).mock
.calls[0][0]?.where;
expect(where).toEqual({ medicationId: "med-1", userId: "user-1" });
});
it("applies no status filter when explicitly passed `status=all`", async () => {
const res = await GET(
makeRequest("limit=25&offset=0&status=all"),
ROUTE_PARAMS,
);
expect(res.status).toBe(200);
const where = vi.mocked(prisma.medicationIntakeEvent.findMany).mock
.calls[0][0]?.where;
expect(where).toEqual({ medicationId: "med-1", userId: "user-1" });
});
it("filters to confirmed-taken rows for `status=taken`", async () => {
await GET(makeRequest("status=taken"), ROUTE_PARAMS);
const where = vi.mocked(prisma.medicationIntakeEvent.findMany).mock
.calls[0][0]?.where;
expect(where).toEqual({
medicationId: "med-1",
userId: "user-1",
takenAt: { not: null },
skipped: false,
});
});
it("filters to skipped rows for `status=skipped`", async () => {
await GET(makeRequest("status=skipped"), ROUTE_PARAMS);
const where = vi.mocked(prisma.medicationIntakeEvent.findMany).mock
.calls[0][0]?.where;
expect(where).toEqual({
medicationId: "med-1",
userId: "user-1",
skipped: true,
});
});
it("filters to taken-or-skipped rows for `status=completed` (the detail-page contract)", async () => {
await GET(makeRequest("status=completed"), ROUTE_PARAMS);
const where = vi.mocked(prisma.medicationIntakeEvent.findMany).mock
.calls[0][0]?.where;
expect(where).toEqual({
medicationId: "med-1",
userId: "user-1",
OR: [
{ takenAt: { not: null }, skipped: false },
{ skipped: true },
],
});
});
it("rejects unknown status values with a 422", async () => {
const res = await GET(makeRequest("status=junk"), ROUTE_PARAMS);
expect(res.status).toBe(422);
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
});
it("propagates the `status` value to the count call so totals match the visible page", async () => {
await GET(makeRequest("status=completed"), ROUTE_PARAMS);
const countArgs = vi.mocked(prisma.medicationIntakeEvent.count).mock
.calls[0][0];
expect(countArgs?.where).toEqual({
medicationId: "med-1",
userId: "user-1",
OR: [
{ takenAt: { not: null }, skipped: false },
{ skipped: true },
],
});
});
});
+26 -3
View File
@@ -177,8 +177,31 @@ export const GET = apiHandler(
return apiError(parsed.error.issues[0].message, 422);
}
const { limit, offset, sortBy, sortDir } = parsed.data;
const where = { medicationId: id, userId: user.id };
const { limit, offset, sortBy, sortDir, status } = parsed.data;
// v1.4.37 W3 — translate the optional `status` filter into a Prisma
// `where` fragment. Default `status:"all"` keeps the contract
// byte-stable for the iOS Swift client and the dashboard tiles that
// were on the wire before this knob existed. The detail-page
// IntakeHistoryListV2 component opts into `status:"completed"` so
// ambiguous "missed / never confirmed" rows
// (`takenAt IS NULL AND skipped = false`) stay out of the user-facing
// table — they were the source of the v1.4.36 regression where rows
// with no takenAt rendered an "Eingenommen" chip.
const statusFilter =
status === "taken"
? { takenAt: { not: null }, skipped: false }
: status === "skipped"
? { skipped: true }
: status === "completed"
? {
OR: [
{ takenAt: { not: null }, skipped: false },
{ skipped: true },
],
}
: {};
const where = { medicationId: id, userId: user.id, ...statusFilter };
const [events, total] = await Promise.all([
prisma.medicationIntakeEvent.findMany({
@@ -192,7 +215,7 @@ export const GET = apiHandler(
annotate({
action: { name: "medication.intake.list" },
meta: { medication_id: id, total },
meta: { medication_id: id, total, status },
});
return apiSuccess({ events, meta: { total, limit, offset } });
+18
View File
@@ -89,6 +89,24 @@ export const listIntakeEventsSchema = z.object({
.optional()
.default("scheduledFor"),
sortDir: z.enum(["asc", "desc"]).optional().default("desc"),
/**
* v1.4.37 W3 — server-side status filter so the medication detail
* page (IntakeHistoryListV2) can hide unconfirmed / planned rows.
*
* - "all" (default): every event, preserves the byte-stable contract
* the iOS Swift client and existing dashboard consumers depend on.
* - "taken": only rows where the dose was confirmed taken
* (`takenAt IS NOT NULL AND skipped = false`).
* - "skipped": only rows the user explicitly skipped (`skipped = true`).
* - "completed": taken OR skipped — anything the user actually
* actioned. Excludes the ambiguous "missed / never confirmed"
* rows (`takenAt IS NULL AND skipped = false`) that the v1 list
* rendered as "verpasst" before the v1 component retired.
*/
status: z
.enum(["all", "taken", "skipped", "completed"])
.optional()
.default("all"),
});
export const updateIntakeEventSchema = z.object({