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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-28 23:36:56 +02:00
1 parent ef21967d12
commit 8d5d039ed1
3 files changed
+358

No files matched your search

@@ -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",
});
});
});
@@ -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<string>();
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 });
},
);
+17
View File
@@ -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.
*