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

@@ -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"],