mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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 });
|
||||
},
|
||||
);
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user