mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
feat(api): add bulk soft-delete for measurements and mood
Add `POST /api/measurements/bulk-delete` and `POST /api/mood-entries/bulk-delete`,
mirroring the bulk-insert conventions (`withIdempotency`, per-user rate
limit, response envelope). Body is `{ ids: string[] }`, capped at 200.
Soft-delete only, ownership-scoped: a single `updateMany` pins `userId` +
`deletedAt: null` and sets `deletedAt` + bumps `syncVersion`, matching the
single-DELETE iOS tombstone contract. A forged or foreign id is a silent
no-op — the returned `deleted` count reflects owned rows only, never a 404
existence leak.
Side effects fan out efficiently: measurements collapse the deleted rows to
the unique `(type, day)` set before recomputing rollups (one recompute per
type-day, not per row) and invalidate the affected status-insight caches;
mood collapses to the unique day set for its rollup recompute. Both bust the
per-user read caches.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
0d69611bbf
commit
ca262d9839
7 files changed
+984
No files matched your search
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* v1.15.13 — POST /api/measurements/bulk-delete.
|
||||
*
|
||||
* Asserts the ownership-scoped soft-delete contract: only owned, not-
|
||||
* already-tombstoned rows are touched; a forged / foreign id is a silent
|
||||
* no-op (no 404 existence leak); the rollup recompute collapses to the
|
||||
* unique `(type, day)` set instead of firing one recompute per row; the
|
||||
* >200-id cap returns 422.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: {
|
||||
findMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
withIdempotency:
|
||||
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
|
||||
(...args: Args) =>
|
||||
fn(...args),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMeasurements: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-rollups", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/lib/rollups/measurement-rollups")
|
||||
>("@/lib/rollups/measurement-rollups");
|
||||
return {
|
||||
// keep the real collapse so the test exercises the actual fan-out logic
|
||||
collapseToTypeDayKeys: actual.collapseToTypeDayKeys,
|
||||
recomputeBucketsForMeasurement: 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 { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/measurements/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(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
// `vi.resetAllMocks()` wipes the factory defaults; the best-effort
|
||||
// post-delete hooks must be re-stubbed to resolved promises (the route
|
||||
// awaits the rollup recompute and `.catch()`es the insight invalidate).
|
||||
vi.mocked(recomputeBucketsForMeasurement).mockResolvedValue(undefined);
|
||||
vi.mocked(invalidateStatusInsightsForTypes).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("POST /api/measurements/bulk-delete", () => {
|
||||
it("soft-deletes only owned, non-tombstoned rows and returns that count", async () => {
|
||||
// Caller asked for 3 ids but only 2 are owned + live.
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T08:00:00Z") },
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T20:00:00Z") },
|
||||
] as never);
|
||||
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({
|
||||
count: 2,
|
||||
} as never);
|
||||
|
||||
const res = await POST(postReq({ ids: ["m1", "m2", "foreign"] }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { deleted: number } };
|
||||
expect(body.data.deleted).toBe(2);
|
||||
|
||||
// updateMany is ownership-scoped + filters tombstones — a forged id
|
||||
// never 404s, it's just excluded from the count.
|
||||
const call = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0];
|
||||
expect(call.where).toMatchObject({
|
||||
id: { in: ["m1", "m2", "foreign"] },
|
||||
userId: "user-1",
|
||||
deletedAt: null,
|
||||
});
|
||||
expect(call.data).toMatchObject({
|
||||
syncVersion: { increment: 1 },
|
||||
});
|
||||
expect(call.data.deletedAt).toBeInstanceOf(Date);
|
||||
|
||||
expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("collapses the rollup recompute to the unique (type, day) set, not per-row", async () => {
|
||||
// 4 deleted rows: 2× WEIGHT on the same UTC day, 1× WEIGHT next day,
|
||||
// 1× PULSE same day as the first → 3 distinct (type, day) keys.
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T08:00:00Z") },
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T20:00:00Z") },
|
||||
{ type: "WEIGHT", measuredAt: new Date("2026-01-02T09:00:00Z") },
|
||||
{ type: "PULSE", measuredAt: new Date("2026-01-01T10:00:00Z") },
|
||||
] as never);
|
||||
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({
|
||||
count: 4,
|
||||
} as never);
|
||||
|
||||
await POST(postReq({ ids: ["a", "b", "c", "d"] }));
|
||||
|
||||
// 4 rows in, 3 distinct (type, day) recomputes out.
|
||||
expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("is a no-op (deleted: 0) when no id is owned — no rollup, no invalidate", async () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
|
||||
const res = await POST(postReq({ ids: ["foreign-1", "foreign-2"] }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { deleted: number } };
|
||||
expect(body.data.deleted).toBe(0);
|
||||
expect(invalidateUserMeasurements).not.toHaveBeenCalled();
|
||||
expect(recomputeBucketsForMeasurement).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a batch over the 200-id cap with 422", async () => {
|
||||
const ids = Array.from({ length: 201 }, (_, i) => `m${i}`);
|
||||
const res = await POST(postReq({ ids }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an empty id array with 422", async () => {
|
||||
const res = await POST(postReq({ ids: [] }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("429s when the rate limit is exceeded", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
|
||||
const res = await POST(postReq({ ids: ["m1"] }));
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* `POST /api/measurements/bulk-delete` — multi-select soft-delete for the
|
||||
* measurements management list (v1.15.13).
|
||||
*
|
||||
* Body:
|
||||
* { ids: string[] } — 1..200 measurement ids, scoped to the caller.
|
||||
*
|
||||
* Soft-deletes (tombstones) every owned, not-already-deleted row in one
|
||||
* `updateMany` — matching the single-DELETE iOS tombstone contract. A
|
||||
* forged / foreign id is a silent no-op (never a 404 existence leak): the
|
||||
* mutation `where` pins `userId` so it only ever touches the caller's rows.
|
||||
*
|
||||
* Response (200):
|
||||
* { deleted: <number of rows tombstoned> }
|
||||
*/
|
||||
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 { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import {
|
||||
recomputeBucketsForMeasurement,
|
||||
collapseToTypeDayKeys,
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
const MAX_IDS_PER_BATCH = 200;
|
||||
const RATE_LIMIT_MAX = 60;
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
const bulkDeleteSchema = z.object({
|
||||
ids: z.array(z.string().min(1)).min(1).max(MAX_IDS_PER_BATCH),
|
||||
});
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulkDelete));
|
||||
|
||||
async function postBulkDelete(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`measurements:bulk-delete:${user.id}`,
|
||||
RATE_LIMIT_MAX,
|
||||
RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many bulk deletions, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = bulkDeleteSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "measurement.bulk-delete.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const { ids } = parsed.data;
|
||||
const now = new Date();
|
||||
|
||||
// Read the soon-to-be-tombstoned rows first so we know which
|
||||
// `(type, day)` buckets need a rollup recompute and which types need
|
||||
// a status-insight cache drop. Ownership-scoped + `deletedAt: null` so
|
||||
// a forged / foreign / already-tombstoned id is excluded here exactly
|
||||
// as it is from the mutation below — no existence leak.
|
||||
const affected = await prisma.measurement.findMany({
|
||||
where: { id: { in: ids }, userId: user.id, deletedAt: null },
|
||||
select: { type: true, measuredAt: true },
|
||||
});
|
||||
|
||||
// Soft-delete (tombstone) in one statement. The `where` pins `userId`
|
||||
// so a forged id for another user is silently a no-op; `deletedAt: null`
|
||||
// keeps the count honest (an already-tombstoned id doesn't re-count).
|
||||
const { count } = await prisma.measurement.updateMany({
|
||||
where: { id: { in: ids }, userId: user.id, deletedAt: null },
|
||||
data: { deletedAt: now, syncVersion: { increment: 1 } },
|
||||
});
|
||||
|
||||
await auditLog("measurement.delete.bulk", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { count },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "measurement.delete.bulk" },
|
||||
meta: { count },
|
||||
});
|
||||
|
||||
if (count > 0) {
|
||||
// v1.4.34 IW-G — bust per-user analytics + achievements + workouts
|
||||
// caches so subsequent reads reflect the deletions.
|
||||
invalidateUserMeasurements(user.id);
|
||||
|
||||
// Collapse the deleted rows to the unique `(type, day)` set BEFORE
|
||||
// recomputing so a 200-row delete spanning one day fires ~1 recompute
|
||||
// per type, not 200 (mirrors the batch-insert path). Best-effort — a
|
||||
// populator hiccup never fails the user's delete.
|
||||
const keys = collapseToTypeDayKeys(
|
||||
affected.map((row) => ({
|
||||
type: row.type as MeasurementType,
|
||||
measuredAt: row.measuredAt,
|
||||
})),
|
||||
);
|
||||
try {
|
||||
for (const k of keys) {
|
||||
await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[measurements] bulk-delete rollup recompute failed", err);
|
||||
}
|
||||
|
||||
// v1.8.0 — drop the cached per-metric assessment rows the deletion
|
||||
// dirties so the next mount / nightly warm pass regenerates against
|
||||
// the reduced history. Fire-and-forget: never blocks the delete.
|
||||
const affectedTypes = Array.from(new Set(keys.map((k) => k.type)));
|
||||
if (affectedTypes.length > 0) {
|
||||
invalidateStatusInsightsForTypes(user.id, affectedTypes).catch((err) => {
|
||||
console.warn(
|
||||
"[measurements] bulk-delete status-insight invalidate failed",
|
||||
err,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return apiSuccess({ deleted: count });
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* v1.15.13 — POST /api/mood-entries/bulk-delete.
|
||||
*
|
||||
* Asserts the ownership-scoped soft-delete contract: only owned, not-
|
||||
* already-tombstoned rows are touched; a forged / foreign id is a silent
|
||||
* no-op (no 404 existence leak); the mood rollup recompute collapses to
|
||||
* the unique day set; the >200-id cap returns 422.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
moodEntry: {
|
||||
findMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
withIdempotency:
|
||||
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
|
||||
(...args: Args) =>
|
||||
fn(...args),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMood: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/mood-rollups", () => ({
|
||||
recomputeMoodBucketsForEntry: 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 { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/mood-entries/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(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
// `vi.resetAllMocks()` wipes the factory default; the route awaits the
|
||||
// best-effort mood-rollup recompute, so re-stub it to a resolved promise.
|
||||
vi.mocked(recomputeMoodBucketsForEntry).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("POST /api/mood-entries/bulk-delete", () => {
|
||||
it("soft-deletes only owned, non-tombstoned rows and bumps syncVersion", async () => {
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
|
||||
{ moodLoggedAt: new Date("2026-01-01T08:00:00Z") },
|
||||
{ moodLoggedAt: new Date("2026-01-02T20:00:00Z") },
|
||||
] as never);
|
||||
vi.mocked(prisma.moodEntry.updateMany).mockResolvedValue({
|
||||
count: 2,
|
||||
} as never);
|
||||
|
||||
const res = await POST(postReq({ ids: ["e1", "e2", "foreign"] }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { deleted: number } };
|
||||
expect(body.data.deleted).toBe(2);
|
||||
|
||||
const call = vi.mocked(prisma.moodEntry.updateMany).mock.calls[0][0];
|
||||
expect(call.where).toMatchObject({
|
||||
id: { in: ["e1", "e2", "foreign"] },
|
||||
userId: "user-1",
|
||||
deletedAt: null,
|
||||
});
|
||||
expect(call.data).toMatchObject({ syncVersion: { increment: 1 } });
|
||||
expect(call.data.deletedAt).toBeInstanceOf(Date);
|
||||
|
||||
expect(invalidateUserMood).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("collapses the rollup recompute to the unique day set, not per-row", async () => {
|
||||
// 3 deleted rows across 2 distinct UTC days → 2 recomputes.
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
|
||||
{ moodLoggedAt: new Date("2026-01-01T08:00:00Z") },
|
||||
{ moodLoggedAt: new Date("2026-01-01T22:00:00Z") },
|
||||
{ moodLoggedAt: new Date("2026-01-02T09:00:00Z") },
|
||||
] as never);
|
||||
vi.mocked(prisma.moodEntry.updateMany).mockResolvedValue({
|
||||
count: 3,
|
||||
} as never);
|
||||
|
||||
await POST(postReq({ ids: ["a", "b", "c"] }));
|
||||
expect(recomputeMoodBucketsForEntry).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("is a no-op (deleted: 0) when no id is owned — no rollup, no invalidate", async () => {
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.moodEntry.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
|
||||
const res = await POST(postReq({ ids: ["foreign-1"] }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { deleted: number } };
|
||||
expect(body.data.deleted).toBe(0);
|
||||
expect(invalidateUserMood).not.toHaveBeenCalled();
|
||||
expect(recomputeMoodBucketsForEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a batch over the 200-id cap with 422", async () => {
|
||||
const ids = Array.from({ length: 201 }, (_, i) => `e${i}`);
|
||||
const res = await POST(postReq({ ids }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.moodEntry.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("429s when the rate limit is exceeded", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
|
||||
const res = await POST(postReq({ ids: ["e1"] }));
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.moodEntry.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* `POST /api/mood-entries/bulk-delete` — multi-select soft-delete for the
|
||||
* mood management list (v1.15.13).
|
||||
*
|
||||
* Body:
|
||||
* { ids: string[] } — 1..200 mood-entry ids, scoped to the caller.
|
||||
*
|
||||
* Soft-deletes (tombstones) every owned, not-already-deleted row in one
|
||||
* `updateMany` — matching the single-DELETE iOS tombstone contract. A
|
||||
* forged / foreign id is a silent no-op (never a 404 existence leak): the
|
||||
* mutation `where` pins `userId` so it only ever touches the caller's rows.
|
||||
*
|
||||
* Response (200):
|
||||
* { deleted: <number of rows tombstoned> }
|
||||
*/
|
||||
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 { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
|
||||
const MAX_IDS_PER_BATCH = 200;
|
||||
const RATE_LIMIT_MAX = 60;
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
const bulkDeleteSchema = z.object({
|
||||
ids: z.array(z.string().min(1)).min(1).max(MAX_IDS_PER_BATCH),
|
||||
});
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulkDelete));
|
||||
|
||||
async function postBulkDelete(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`mood-entries:bulk-delete:${user.id}`,
|
||||
RATE_LIMIT_MAX,
|
||||
RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many bulk deletions, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = bulkDeleteSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "mood.bulk-delete.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const { ids } = parsed.data;
|
||||
const now = new Date();
|
||||
|
||||
// Read the soon-to-be-tombstoned rows first so we know which days need
|
||||
// a mood-rollup recompute. Ownership-scoped + `deletedAt: null` so a
|
||||
// forged / foreign / already-tombstoned id is excluded here exactly as
|
||||
// it is from the mutation below — no existence leak.
|
||||
const affected = await prisma.moodEntry.findMany({
|
||||
where: { id: { in: ids }, userId: user.id, deletedAt: null },
|
||||
select: { moodLoggedAt: true },
|
||||
});
|
||||
|
||||
// Soft-delete (tombstone) in one statement. The `where` pins `userId`
|
||||
// so a forged id for another user is silently a no-op; `deletedAt: null`
|
||||
// keeps the count honest (an already-tombstoned id doesn't re-count).
|
||||
const { count } = await prisma.moodEntry.updateMany({
|
||||
where: { id: { in: ids }, userId: user.id, deletedAt: null },
|
||||
data: { deletedAt: now, syncVersion: { increment: 1 } },
|
||||
});
|
||||
|
||||
await auditLog("mood.delete.bulk", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { count },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "mood.delete.bulk" },
|
||||
meta: { count },
|
||||
});
|
||||
|
||||
if (count > 0) {
|
||||
// v1.4.34 IW-G — bust per-user mood + achievements + analytics caches.
|
||||
invalidateUserMood(user.id);
|
||||
|
||||
// v1.4.39 W-MOOD — collapse the deleted rows to the unique
|
||||
// `(user, dayStart)` set BEFORE recomputing so a bulk delete spanning
|
||||
// one day fires one recompute, not one per row. Best-effort — a
|
||||
// populator hiccup never fails the user's delete.
|
||||
const touchedDayStarts = new Set<number>();
|
||||
for (const row of affected) {
|
||||
const d = row.moodLoggedAt;
|
||||
touchedDayStarts.add(
|
||||
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()),
|
||||
);
|
||||
}
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from(touchedDayStarts).map((t) =>
|
||||
recomputeMoodBucketsForEntry(user.id, new Date(t)),
|
||||
),
|
||||
);
|
||||
} catch (rollupErr) {
|
||||
annotate({
|
||||
meta: {
|
||||
mood_rollup_write_failed: true,
|
||||
mood_rollup_write_error:
|
||||
rollupErr instanceof Error ? rollupErr.message : String(rollupErr),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return apiSuccess({ deleted: count });
|
||||
}
|
||||
@@ -579,6 +579,26 @@ const deleteByExternalIdsResponse = z
|
||||
})
|
||||
.meta({ id: "MeasurementsDeleteByExternalIdsResult" });
|
||||
|
||||
// v1.15.13 — multi-select bulk soft-delete for the measurements
|
||||
// management list. Ids are scoped to the caller; forged / foreign ids
|
||||
// are silent no-ops (no existence leak). Soft-delete (tombstone), so the
|
||||
// rows surface in the `/api/sync/changes` delta feed.
|
||||
const bulkDeleteMeasurementsRequest = z
|
||||
.object({
|
||||
ids: z.array(z.string().min(1)).min(1).max(200),
|
||||
})
|
||||
.meta({
|
||||
id: "MeasurementsBulkDeleteRequest",
|
||||
description:
|
||||
"1..200 measurement ids, scoped to the caller. Forged / foreign ids are silently skipped (no existence leak).",
|
||||
});
|
||||
|
||||
const bulkDeleteMeasurementsResponse = z
|
||||
.object({
|
||||
deleted: z.number().int().nonnegative(),
|
||||
})
|
||||
.meta({ id: "MeasurementsBulkDeleteResult" });
|
||||
|
||||
const measurementResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
@@ -3444,6 +3464,34 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/measurements/bulk-delete": {
|
||||
post: {
|
||||
tags: ["Measurements"],
|
||||
summary: "Bulk soft-delete measurements",
|
||||
description:
|
||||
"Soft-deletes (tombstones) up to 200 of the caller's measurement rows in one call, mirroring the single-DELETE contract. Idempotent via the `Idempotency-Key` header; rate-limited `measurements:bulk-delete:<userId>`. Forged / foreign / already-deleted ids are silently skipped — `deleted` is the count of rows actually tombstoned. The deletions surface in `/api/sync/changes` as tombstones.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: bulkDeleteMeasurementsRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Bulk delete processed.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
bulkDeleteMeasurementsResponse,
|
||||
"MeasurementsBulkDeleteResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/sleep/night": {
|
||||
get: {
|
||||
tags: ["Measurements"],
|
||||
|
||||
Reference in new issue
Block a user