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

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