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:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-07 19:24:16 +02:00
1 parent 0d69611bbf
commit ca262d9839
7 files changed
+984

No files matched your search

+77
View File
@@ -1508,6 +1508,18 @@ paths:
type: string
enum:
- rollup
- in: query
name: sourceEq
schema:
type: string
enum:
- MANUAL
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
- in: query
name: groupBy
schema:
@@ -1698,6 +1710,31 @@ paths:
"401": *a1
"422": *a3
"429": *a4
/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:
$ref: "#/components/schemas/MeasurementsBulkDeleteRequest"
responses:
"200":
description: Bulk delete processed.
content:
application/json:
schema:
$ref: "#/components/schemas/MeasurementsBulkDeleteResponse"
"401": *a1
"422": *a3
"429": *a4
/api/sleep/night:
get:
tags:
@@ -3998,6 +4035,19 @@ components:
- externalIds
description: iOS deletion-sync. Up to 500 externalIds per call; matching rows owned by other users are silently skipped
(cross-user 404 guard).
MeasurementsBulkDeleteRequest:
type: object
properties:
ids:
minItems: 1
maxItems: 200
type: array
items:
type: string
minLength: 1
required:
- ids
description: 1..200 measurement ids, scoped to the caller. Forged / foreign ids are silently skipped (no existence leak).
DeviceRegisterRequest:
type: object
properties:
@@ -7012,6 +7062,33 @@ components:
required:
- deletedCount
additionalProperties: false
MeasurementsBulkDeleteResponse:
type: object
properties:
data:
$ref: "#/components/schemas/MeasurementsBulkDeleteResult"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
MeasurementsBulkDeleteResult:
type: object
properties:
deleted:
type: integer
minimum: 0
maximum: 9007199254740991
required:
- deleted
additionalProperties: false
SleepNightResponse:
type: object
properties:
@@ -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 });
}
+48
View File
@@ -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"],
@@ -0,0 +1,250 @@
/**
* v1.15.13 — bulk soft-delete + source filter, real-Postgres integration.
*
* Covers, against the real testcontainer:
* - `POST /api/measurements/bulk-delete` soft-deletes only the caller's
* rows; a foreign id is a silent no-op (no existence leak), bumps
* `syncVersion`, sets `deletedAt`, and the count reflects owned rows
* only.
* - Idempotency replay returns the same `deleted` count without
* re-mutating (a second distinct call would re-bump syncVersion).
* - `POST /api/mood-entries/bulk-delete` mirrors the same contract.
* - `sourceEq` (measurements) + `source` (mood) narrow the list reads.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
const USER_A = "user-bulkdel-a";
const USER_B = "user-bulkdel-b";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
async function seedUser(id: string, username: string) {
const prisma = getPrismaClient();
await prisma.user.create({
data: { id, username, email: `${username}@example.test`, timezone: "UTC" },
});
}
async function loginAs(id: string) {
const session = await getPrismaClient().session.create({
data: { userId: id, expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
});
cookieJar.clear();
cookieJar.set("healthlog_session", session.id);
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
await seedUser(USER_A, "bulkdel-a");
await seedUser(USER_B, "bulkdel-b");
});
function postReq(path: string, body: unknown, idempotencyKey?: string) {
return new NextRequest(`http://localhost${path}`, {
method: "POST",
headers: {
"content-type": "application/json",
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
},
body: JSON.stringify(body),
});
}
describe("POST /api/measurements/bulk-delete (real Postgres)", () => {
it("soft-deletes owned rows, skips a foreign id, bumps syncVersion", async () => {
const prisma = getPrismaClient();
const now = new Date();
const mine = await prisma.measurement.createManyAndReturn({
data: [
{ userId: USER_A, type: "WEIGHT", value: 80, unit: "kg", source: "MANUAL", measuredAt: now },
{ userId: USER_A, type: "WEIGHT", value: 81, unit: "kg", source: "MANUAL", measuredAt: new Date(now.getTime() - 1000) },
],
});
const theirs = await prisma.measurement.create({
data: { userId: USER_B, type: "WEIGHT", value: 70, unit: "kg", source: "MANUAL", measuredAt: now },
});
await loginAs(USER_A);
const { POST } = await import("@/app/api/measurements/bulk-delete/route");
const res = await POST(
postReq("/api/measurements/bulk-delete", {
ids: [mine[0].id, mine[1].id, theirs.id],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { deleted: number } };
// Only the two owned rows count; the foreign id is a silent no-op.
expect(body.data.deleted).toBe(2);
const minedAfter = await prisma.measurement.findMany({
where: { id: { in: [mine[0].id, mine[1].id] } },
});
for (const row of minedAfter) {
expect(row.deletedAt).not.toBeNull();
// Measurement.syncVersion defaults to 1; one bulk delete bumps it to 2.
expect(row.syncVersion).toBe(2);
}
// The other user's row is untouched (default syncVersion = 1).
const theirsAfter = await prisma.measurement.findUnique({
where: { id: theirs.id },
});
expect(theirsAfter!.deletedAt).toBeNull();
expect(theirsAfter!.syncVersion).toBe(1);
});
it("idempotency replay returns the cached count without re-mutating", async () => {
const prisma = getPrismaClient();
const row = await prisma.measurement.create({
data: { userId: USER_A, type: "WEIGHT", value: 80, unit: "kg", source: "MANUAL", measuredAt: new Date() },
});
await loginAs(USER_A);
const { POST } = await import("@/app/api/measurements/bulk-delete/route");
const key = "idem-meas-bulk-1";
const first = await POST(
postReq("/api/measurements/bulk-delete", { ids: [row.id] }, key),
);
expect(first.status).toBe(200);
expect(((await first.json()) as { data: { deleted: number } }).data.deleted).toBe(1);
// Replay with the SAME key → cached body, same count, no re-mutation.
const replay = await POST(
postReq("/api/measurements/bulk-delete", { ids: [row.id] }, key),
);
expect(replay.status).toBe(200);
expect(replay.headers.get("X-Idempotent-Replay")).toBe("true");
expect(((await replay.json()) as { data: { deleted: number } }).data.deleted).toBe(1);
// Measurement.syncVersion defaults to 1; the single real delete bumped
// it to 2 and the replay did NOT re-run the mutation (so it stays 2).
const after = await prisma.measurement.findUnique({ where: { id: row.id } });
expect(after!.syncVersion).toBe(2);
});
it("rejects a >200-id batch with 422", async () => {
await loginAs(USER_A);
const { POST } = await import("@/app/api/measurements/bulk-delete/route");
const ids = Array.from({ length: 201 }, (_, i) => `id-${i}`);
const res = await POST(postReq("/api/measurements/bulk-delete", { ids }));
expect(res.status).toBe(422);
});
});
describe("POST /api/mood-entries/bulk-delete (real Postgres)", () => {
it("soft-deletes owned rows, skips a foreign id, bumps syncVersion", async () => {
const prisma = getPrismaClient();
const mine = await prisma.moodEntry.createManyAndReturn({
data: [
{ userId: USER_A, date: "2026-01-01", mood: "GUT", score: 4, source: "MANUAL", moodLoggedAt: new Date("2026-01-01T08:00:00Z"), tz: "UTC" },
{ userId: USER_A, date: "2026-01-02", mood: "OKAY", score: 3, source: "MANUAL", moodLoggedAt: new Date("2026-01-02T08:00:00Z"), tz: "UTC" },
],
});
const theirs = await prisma.moodEntry.create({
data: { userId: USER_B, date: "2026-01-01", mood: "LAUSIG", score: 1, source: "MANUAL", moodLoggedAt: new Date("2026-01-01T09:00:00Z"), tz: "UTC" },
});
await loginAs(USER_A);
const { POST } = await import("@/app/api/mood-entries/bulk-delete/route");
const res = await POST(
postReq("/api/mood-entries/bulk-delete", {
ids: [mine[0].id, mine[1].id, theirs.id],
}),
);
expect(res.status).toBe(200);
expect(((await res.json()) as { data: { deleted: number } }).data.deleted).toBe(2);
const after = await prisma.moodEntry.findMany({
where: { id: { in: [mine[0].id, mine[1].id] } },
});
for (const row of after) {
expect(row.deletedAt).not.toBeNull();
expect(row.syncVersion).toBe(1);
}
const theirsAfter = await prisma.moodEntry.findUnique({
where: { id: theirs.id },
});
expect(theirsAfter!.deletedAt).toBeNull();
});
});
describe("source filter narrows the list reads (real Postgres)", () => {
it("measurements sourceEq returns only the matching source", async () => {
const prisma = getPrismaClient();
const now = new Date();
await prisma.measurement.createMany({
data: [
{ userId: USER_A, type: "WEIGHT", value: 80, unit: "kg", source: "MANUAL", measuredAt: now },
{ userId: USER_A, type: "WEIGHT", value: 81, unit: "kg", source: "WITHINGS", measuredAt: new Date(now.getTime() - 1000) },
{ userId: USER_A, type: "WEIGHT", value: 82, unit: "kg", source: "WITHINGS", measuredAt: new Date(now.getTime() - 2000) },
],
});
await loginAs(USER_A);
const { GET } = await import("@/app/api/measurements/route");
const res = await GET(
new NextRequest("http://localhost/api/measurements?sourceEq=WITHINGS"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
measurements: Array<{ source: string }>;
meta: { total: number };
};
};
expect(body.data.measurements).toHaveLength(2);
expect(body.data.measurements.every((m) => m.source === "WITHINGS")).toBe(true);
expect(body.data.meta.total).toBe(2);
});
it("mood source returns only the matching source", async () => {
const prisma = getPrismaClient();
await prisma.moodEntry.createMany({
data: [
{ userId: USER_A, date: "2026-01-01", mood: "GUT", score: 4, source: "MANUAL", moodLoggedAt: new Date("2026-01-01T08:00:00Z"), tz: "UTC" },
{ userId: USER_A, date: "2026-01-02", mood: "OKAY", score: 3, source: "TELEGRAM", moodLoggedAt: new Date("2026-01-02T08:00:00Z"), tz: "UTC" },
],
});
await loginAs(USER_A);
const { GET } = await import("@/app/api/mood-entries/route");
const res = await GET(
new NextRequest("http://localhost/api/mood-entries?source=TELEGRAM"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { entries: Array<{ source: string }>; meta: { total: number } };
};
expect(body.data.entries).toHaveLength(1);
expect(body.data.entries[0].source).toBe("TELEGRAM");
expect(body.data.meta.total).toBe(1);
});
});