From 8d5d039ed187f1f0acea0d790fdc72f70c29c584 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 28 May 2026 23:36:56 +0200 Subject: [PATCH] feat(meds): bulk-delete intake endpoint for the detail-page preview multi-select The v1.5.5 detail-page intake-history preview offers a multi-select that posts the selected eventIds in one round-trip instead of firing N concurrent DELETEs from the client. The endpoint: - Asserts ownership through the shared `assertMedicationOwnership` helper so the 404 leak shape stays identical across `[id]/**`. - Reads rows scoped by `(eventId IN, userId, medicationId)` so a leaked id from another medication's history never deletes anything. - Runs `deleteMany` scoped by the same predicate. - Recomputes the compliance rollup once per unique dayKey (not once per event), so a 14-row delete spanning two days closes in two recompute trips and usually 1-3. - Returns `{ deleted: number }` carrying the real `deleteMany` count so the client toast announces the truth. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../bulk-delete/__tests__/route.test.ts | 192 ++++++++++++++++++ .../[id]/intake/bulk-delete/route.ts | 149 ++++++++++++++ src/lib/validations/medication.ts | 17 ++ 3 files changed, 358 insertions(+) create mode 100644 src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts create mode 100644 src/app/api/medications/[id]/intake/bulk-delete/route.ts diff --git a/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts new file mode 100644 index 000000000..f642898c7 --- /dev/null +++ b/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts @@ -0,0 +1,192 @@ +/** + * v1.5.5 — POST /api/medications/[id]/intake/bulk-delete tests. + * + * Pins the contract the detail-page intake-history preview's + * multi-select Löschen depends on: + * + * - Asserts ownership through the shared `assertMedicationOwnership` + * helper. Negative branch returns the helper's 404 response. + * - Reads rows scoped by `(eventId IN, userId, medicationId)` so a + * leaked id from another medication never deletes anything. + * - Recomputes the rollup once per unique dayKey (not once per + * event), so a 14-row delete spanning two days closes in two + * SQL recompute trips. + * - 422 on malformed body via `bulkDeleteIntakeEventsSchema`. + * - 200 with `{ deleted: number }` carrying the real `deleteMany` + * count so the client toast announces the truth. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + medication: { findUnique: vi.fn() }, + medicationIntakeEvent: { + findMany: vi.fn(), + deleteMany: vi.fn(), + }, + auditLog: { create: vi.fn() }, + }, +})); + +vi.mock("@/lib/medications/route-guards", () => ({ + assertMedicationOwnership: vi.fn().mockResolvedValue(null), +})); + +vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ + dayKeyForScheduledFor: vi.fn((d: Date) => { + const iso = d.toISOString(); + return iso.slice(0, 10); + }), + recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMedications: 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 { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { + dayKeyForScheduledFor, + recomputeMedicationComplianceForDay, +} from "@/lib/rollups/medication-compliance-rollups"; + +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 ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) }; + +function postWithBody(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/medications/med-1/intake/bulk-delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(assertMedicationOwnership).mockResolvedValue(null); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timezone: "Europe/Berlin", + } as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.deleteMany).mockResolvedValue({ + count: 0, + } as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); +}); + +describe("POST /api/medications/[id]/intake/bulk-delete", () => { + it("422 on missing body", async () => { + const res = await POST(postWithBody({}), ROUTE_PARAMS); + expect(res.status).toBe(422); + expect(prisma.medicationIntakeEvent.deleteMany).not.toHaveBeenCalled(); + }); + + it("422 on empty eventIds", async () => { + const res = await POST(postWithBody({ eventIds: [] }), ROUTE_PARAMS); + expect(res.status).toBe(422); + }); + + it("returns the 404 from the shared ownership helper", async () => { + vi.mocked(assertMedicationOwnership).mockResolvedValueOnce( + new Response(null, { status: 404 }) as never, + ); + const res = await POST( + postWithBody({ eventIds: ["evt-1"] }), + ROUTE_PARAMS, + ); + expect(res.status).toBe(404); + expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled(); + }); + + it("returns deleted:0 + skips recompute when no matching rows are found", async () => { + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce( + [] as never, + ); + const res = await POST( + postWithBody({ eventIds: ["evt-leaked", "evt-other"] }), + ROUTE_PARAMS, + ); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data).toEqual({ deleted: 0 }); + expect(prisma.medicationIntakeEvent.deleteMany).not.toHaveBeenCalled(); + expect(recomputeMedicationComplianceForDay).not.toHaveBeenCalled(); + }); + + it("recomputes the rollup once per unique dayKey", async () => { + const day1 = new Date(Date.UTC(2026, 4, 27, 8, 0, 0)); + const day2 = new Date(Date.UTC(2026, 4, 28, 8, 0, 0)); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([ + { id: "evt-a", scheduledFor: day1 }, + { id: "evt-b", scheduledFor: day1 }, + { id: "evt-c", scheduledFor: day2 }, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.deleteMany).mockResolvedValueOnce({ + count: 3, + } as never); + + const res = await POST( + postWithBody({ eventIds: ["evt-a", "evt-b", "evt-c"] }), + ROUTE_PARAMS, + ); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data).toEqual({ deleted: 3 }); + + expect(dayKeyForScheduledFor).toHaveBeenCalledTimes(3); + // Three rows, two unique dayKeys → two recompute calls. + expect(recomputeMedicationComplianceForDay).toHaveBeenCalledTimes(2); + }); + + it("scopes deleteMany by both userId and medicationId", async () => { + const day1 = new Date(Date.UTC(2026, 4, 27, 8, 0, 0)); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([ + { id: "evt-a", scheduledFor: day1 }, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.deleteMany).mockResolvedValueOnce({ + count: 1, + } as never); + + await POST(postWithBody({ eventIds: ["evt-a"] }), ROUTE_PARAMS); + + const deleteArg = vi.mocked(prisma.medicationIntakeEvent.deleteMany).mock + .calls[0][0]?.where; + expect(deleteArg).toEqual({ + id: { in: ["evt-a"] }, + userId: "user-1", + medicationId: "med-1", + }); + }); +}); diff --git a/src/app/api/medications/[id]/intake/bulk-delete/route.ts b/src/app/api/medications/[id]/intake/bulk-delete/route.ts new file mode 100644 index 000000000..b622c5bcd --- /dev/null +++ b/src/app/api/medications/[id]/intake/bulk-delete/route.ts @@ -0,0 +1,149 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { bulkDeleteIntakeEventsSchema } from "@/lib/validations/medication"; +import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { + dayKeyForScheduledFor, + recomputeMedicationComplianceForDay, +} from "@/lib/rollups/medication-compliance-rollups"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { NextRequest } from "next/server"; + +type RouteParams = { params: Promise<{ id: string }> }; + +/** + * v1.5.5 D-3 §9.5 + §11 — bulk-delete N intake rows in one transaction + * so the detail-page intake-history preview can offer multi-select → + * Löschen without the user firing N concurrent DELETEs from the + * client. The endpoint: + * + * - Asserts medication ownership via the shared helper (so the 404 + * leak shape stays identical across `[id]/**`). + * - Loads only the (id, scheduledFor) for events that match the body + * ids AND the asserted (user, medication) tuple. Ids that don't + * match are silently dropped — the client surface is "delete these + * rows from this medication" and an attacker-supplied id from + * another medication never deletes anything. + * - Runs `deleteMany` scoped by the user-and-medication predicate. + * - Recomputes the compliance rollup once per affected dayKey (not + * once per event), so the rollup tier converges after a 14-row + * bulk delete in at most 14 SQL trips and usually 1-3. + * - Audits the bulk action with the deleted count. + * + * Failure modes: + * + * - 401 unauthenticated — caught by `requireAuth`. + * - 404 unknown / not-owned medication — `assertMedicationOwnership`. + * - 422 malformed body — Zod via `returnAllZodIssues`. + * - 200 with `{ deleted: number }` on success; `deleted` reports the + * real `deleteMany` count so the client can show a precise toast. + */ +export const POST = 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 { data: body, error: jsonError } = await safeJson(request); + if (jsonError) return jsonError; + const parsed = bulkDeleteIntakeEventsSchema.safeParse(body); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422); + } + const { eventIds } = parsed.data; + + // Tz the rollups are anchored on. The detail-page caller persists + // intakes through the same hook, so reading `user.timezone` here + // matches the write-path's recompute call. + const userRow = await prisma.user.findUnique({ + where: { id: user.id }, + select: { timezone: true }, + }); + const tz = userRow?.timezone ?? null; + + // Read the dayKeys of every row we're about to remove so the + // post-delete recompute knows which day-buckets need to converge. + // Scope by (user, medication) so a leaked id from another + // medication's history never affects this bulk action. + const targetRows = await prisma.medicationIntakeEvent.findMany({ + where: { + id: { in: eventIds }, + userId: user.id, + medicationId: id, + }, + select: { id: true, scheduledFor: true }, + }); + + if (targetRows.length === 0) { + return apiSuccess({ deleted: 0 }); + } + + const dayKeysToRefresh = new Set(); + for (const row of targetRows) { + dayKeysToRefresh.add(dayKeyForScheduledFor(row.scheduledFor, tz)); + } + + const { count } = await prisma.medicationIntakeEvent.deleteMany({ + where: { + id: { in: targetRows.map((r) => r.id) }, + userId: user.id, + medicationId: id, + }, + }); + + // Best-effort rollup refresh — one trip per unique dayKey so a + // 14-row delete spanning two days closes in two SQL trips. The + // helper is idempotent and the catch swallows transient + // populator failures (same contract as the single-event delete). + for (const dayKey of dayKeysToRefresh) { + try { + await recomputeMedicationComplianceForDay(user.id, id, dayKey, tz); + } catch (err) { + annotate({ + action: { + name: "medications.intake.bulk-delete.recompute-failed", + entity_type: "medication", + entity_id: id, + }, + meta: { + day_key: dayKey, + error: err instanceof Error ? err.message : String(err), + }, + }); + } + } + + await auditLog("medication.intake.bulk_delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + medicationId: id, + requestedCount: eventIds.length, + deletedCount: count, + }, + }); + + annotate({ + action: { + name: "medications.intake.bulk-delete", + entity_type: "medication", + entity_id: id, + }, + meta: { deleted_count: count, day_buckets: dayKeysToRefresh.size }, + }); + + invalidateUserMedications(user.id); + + return apiSuccess({ deleted: count }); + }, +); diff --git a/src/lib/validations/medication.ts b/src/lib/validations/medication.ts index 631a0c3b3..f2668925c 100644 --- a/src/lib/validations/medication.ts +++ b/src/lib/validations/medication.ts @@ -370,6 +370,23 @@ export const updateIntakeEventSchema = z.object({ .optional(), }); +/** + * v1.5.5 — bulk-delete request body. The detail-page intake-history + * preview surfaces a multi-select that posts the resulting eventIds + * here. The cap matches `listIntakeEventsSchema.limit` (500) so the + * client never selects more rows than the table can return at once. + * Server-side guarantees scoped-by-medication ownership via + * `assertMedicationOwnership` + a `userId` predicate on the + * `deleteMany`. + */ +export const bulkDeleteIntakeEventsSchema = z.object({ + eventIds: z.array(z.string().min(1).max(64)).min(1).max(500), +}); + +export type BulkDeleteIntakeEventsInput = z.infer< + typeof bulkDeleteIntakeEventsSchema +>; + /** * v1.4.25 W19b — inventory (pen / vial) CRUD validators. *