From 959adec899c0852bc827a90136f05e87f08ff737 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 13:07:05 +0200 Subject: [PATCH] feat(api): DELETE /api/measurements/by-external-ids for iOS deletion-sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HealthKit's API does not surface a stream of deleted UUIDs, so the iOS HealthLog client performs a periodic 30-day reconciliation: it diffs HealthKit's current UUID set against the server's externalIds and posts the surplus here. This endpoint deletes the user's matching rows and never touches another user's data (Prisma's deleteMany with the userId predicate is the cross-user 404 guard). Contract: - Body: { externalIds: string[] } with min 0, max 500 entries. - Auth: requireAuth() — session cookie or bearer. - Empty array: returns { deletedCount: 0 } with 200 (no-op, not a failure — matches the ingest path's symmetric tolerance). - Over-cap: 422 with errorCode "measurement.delete.too_large" so the iOS client can surface a specific diagnostic. Integration coverage: happy path, cross-user 404 guard, empty array, and batch-cap rejection. The cap (500) mirrors POST /measurements/batch so the two surfaces share one operational ceiling. OpenAPI: registered as DELETE on /api/measurements/by-external-ids with the Measurements tag; req/res schemas reuse the same z.array cap the runtime validator enforces. openapi.yaml regenerated. --- docs/api/openapi.yaml | 67 ++++++ .../api/measurements/by-external-ids/route.ts | 107 ++++++++++ src/lib/openapi/routes.ts | 44 ++++ .../measurements-batch-delete.test.ts | 193 ++++++++++++++++++ 4 files changed, 411 insertions(+) create mode 100644 src/app/api/measurements/by-external-ids/route.ts create mode 100644 tests/integration/measurements-batch-delete.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3504921f1..9ea27ec2c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -538,6 +538,49 @@ components: - measuredAt - source type: object + MeasurementsDeleteByExternalIdsRequest: + description: + iOS deletion-sync. Up to 500 externalIds per call; matching rows owned by other users are silently skipped + (cross-user 404 guard). + properties: + externalIds: + items: + maxLength: 120 + minLength: 1 + type: string + maxItems: 500 + minItems: 0 + type: array + required: + - externalIds + type: object + MeasurementsDeleteByExternalIdsResponse: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/MeasurementsDeleteByExternalIdsResult" + error: + type: "null" + meta: + additionalProperties: false + properties: + requestId: + type: string + type: object + required: + - data + - error + type: object + MeasurementsDeleteByExternalIdsResult: + additionalProperties: false + properties: + deletedCount: + maximum: 9007199254740991 + minimum: 0 + type: integer + required: + - deletedCount + type: object PasskeyLoginVerifyRequest: description: Passkey assertion verification. Same native-client token issuance as the password path. properties: @@ -1152,6 +1195,30 @@ paths: summary: Apple Health batch ingest tags: - Measurements + /api/measurements/by-external-ids: + delete: + description: + Removes the user's measurement rows whose externalId is in the request list. Rows owned by another user are + silently skipped (cross-user 404 guard). Up to 500 externalIds per call. + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/MeasurementsDeleteByExternalIdsRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MeasurementsDeleteByExternalIdsResponse" + description: Delete batch processed. + "401": *a1 + "422": *a2 + "429": *a3 + summary: Delete measurements by external ID (iOS deletion-sync) + tags: + - Measurements servers: - description: Production url: https://healthlog.bombeck.io diff --git a/src/app/api/measurements/by-external-ids/route.ts b/src/app/api/measurements/by-external-ids/route.ts new file mode 100644 index 000000000..eb4d3d673 --- /dev/null +++ b/src/app/api/measurements/by-external-ids/route.ts @@ -0,0 +1,107 @@ +/** + * `DELETE /api/measurements/by-external-ids` — iOS deletion reconciliation. + * + * HealthKit's API does not surface a stream of deleted UUIDs, so the + * iOS client performs a periodic 30-day window reconciliation: it pulls + * the current set of HealthKit sample UUIDs in the window and posts the + * externalIds the server holds that are no longer present. This endpoint + * accepts that batch-delete list and removes every matching row owned by + * the calling user. + * + * Idempotency contract: + * - Cross-user safety: rows owned by another user are silently skipped + * (Prisma's `deleteMany` with `userId` in the where-clause guarantees + * this), so a duplicate / replayed delete is a no-op rather than a + * 401. + * - Empty arrays return `{ deletedCount: 0 }` with 200 — the iOS + * client should not consider that a failure. + * - The batch cap mirrors the ingest path (`/api/measurements/batch`) + * so the two surfaces share the same operational ceiling. + */ +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, + safeJson, +} from "@/lib/api-response"; + +const MAX_BATCH_ENTRIES = 500; + +const payloadSchema = z.object({ + externalIds: z + .array(z.string().min(1).max(120)) + .min(0) + .max(MAX_BATCH_ENTRIES), +}); + +export const DELETE = apiHandler(deleteByExternalIds); + +async function deleteByExternalIds(request: NextRequest): Promise { + const { user } = await requireAuth(); + + const { data: rawBody, error: jsonError } = await safeJson(request); + if (jsonError) return jsonError; + + // Distinguish "too many entries" from "validation failed" so the iOS + // client surfaces a useful diagnostic — same shape the ingest endpoint + // uses for its symmetric cap. + if ( + typeof rawBody === "object" && + rawBody !== null && + "externalIds" in rawBody && + Array.isArray((rawBody as { externalIds: unknown }).externalIds) && + (rawBody as { externalIds: unknown[] }).externalIds.length > + MAX_BATCH_ENTRIES + ) { + return apiError(`Batch exceeds the ${MAX_BATCH_ENTRIES}-entry limit`, 422, { + errorCode: "measurement.delete.too_large", + }); + } + + const parsed = payloadSchema.safeParse(rawBody); + if (!parsed.success) { + return apiError(parsed.error.issues[0]?.message ?? "Invalid batch", 422); + } + + const { externalIds } = parsed.data; + + if (externalIds.length === 0) { + annotate({ + action: { name: "measurement.delete.by_external_ids" }, + meta: { processed: 0, deleted: 0 }, + }); + return apiSuccess({ deletedCount: 0 }); + } + + // Prisma `deleteMany` with the userId predicate is the cross-user + // 404 guard: rows belonging to other users are simply not matched. + // No need for a pre-flight findMany — the unique index on + // `(userId, type, source, externalId)` keeps the matching set small + // even when the user has overlapping HealthKit history. + const result = await prisma.measurement.deleteMany({ + where: { + userId: user.id, + externalId: { in: externalIds }, + }, + }); + + await auditLog("measurement.delete.by_external_ids", { + userId: user.id, + ipAddress: getClientIp(request), + details: { processed: externalIds.length, deleted: result.count }, + }); + + annotate({ + action: { name: "measurement.delete.by_external_ids" }, + meta: { processed: externalIds.length, deleted: result.count }, + }); + + return apiSuccess({ deletedCount: result.count }); +} diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 8c08d9de7..bc83862d3 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -267,6 +267,22 @@ const batchResponse = z }) .meta({ id: "AppleHealthBatchResponse" }); +const deleteByExternalIdsRequest = z + .object({ + externalIds: z.array(z.string().min(1).max(120)).min(0).max(500), + }) + .meta({ + id: "MeasurementsDeleteByExternalIdsRequest", + description: + "iOS deletion-sync. Up to 500 externalIds per call; matching rows owned by other users are silently skipped (cross-user 404 guard).", + }); + +const deleteByExternalIdsResponse = z + .object({ + deletedCount: z.number().int().nonnegative(), + }) + .meta({ id: "MeasurementsDeleteByExternalIdsResult" }); + const measurementResource = z .object({ id: z.string(), @@ -467,6 +483,34 @@ export const openApiPaths: NonNullable = { }, }, }, + "/api/measurements/by-external-ids": { + delete: { + tags: ["Measurements"], + summary: "Delete measurements by external ID (iOS deletion-sync)", + description: + "Removes the user's measurement rows whose externalId is in the request list. Rows owned by another user are silently skipped (cross-user 404 guard). Up to 500 externalIds per call.", + requestBody: { + required: true, + content: { + "application/json": { schema: deleteByExternalIdsRequest }, + }, + }, + responses: { + "200": { + description: "Delete batch processed.", + content: { + "application/json": { + schema: dataEnvelope( + deleteByExternalIdsResponse, + "MeasurementsDeleteByExternalIdsResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/devices": { post: { tags: ["Devices"], diff --git a/tests/integration/measurements-batch-delete.test.ts b/tests/integration/measurements-batch-delete.test.ts new file mode 100644 index 000000000..205135471 --- /dev/null +++ b/tests/integration/measurements-batch-delete.test.ts @@ -0,0 +1,193 @@ +/** + * Integration suite for `DELETE /api/measurements/by-external-ids` — + * the iOS deletion-sync endpoint. Asserts the contract the iOS client + * relies on for HealthKit reconciliation: + * - Happy path: matching rows are deleted, deletedCount equals the + * number actually removed. + * - Cross-user 404 guard: another user's rows that happen to share an + * externalId remain untouched. + * - Empty array: returns 200 with deletedCount = 0 (not a failure). + * - Batch size cap: 501 externalIds returns 422 with a documented + * errorCode so the client can surface the diagnostic. + */ +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 TEST_USER_ID = "user-batch-delete-test"; +const OTHER_USER_ID = "user-batch-delete-other"; + +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), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + await getPrismaClient().user.create({ + data: { + id: TEST_USER_ID, + username: "batch-delete", + email: "batch-delete@example.test", + }, + }); + await getPrismaClient().user.create({ + data: { + id: OTHER_USER_ID, + username: "batch-delete-other", + email: "batch-delete-other@example.test", + }, + }); + const session = await getPrismaClient().session.create({ + data: { + userId: TEST_USER_ID, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +function makeRequest(body: { externalIds: string[] }): NextRequest { + return new NextRequest("http://localhost/api/measurements/by-external-ids", { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +async function seedMeasurement(opts: { + userId: string; + externalId: string; + value?: number; +}): Promise { + await getPrismaClient().measurement.create({ + data: { + userId: opts.userId, + type: "WEIGHT", + value: opts.value ?? 80, + unit: "kg", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-05-09T07:30:00.000Z"), + externalId: opts.externalId, + }, + }); +} + +describe("DELETE /api/measurements/by-external-ids (real Postgres)", () => { + it("removes matching rows owned by the caller and returns deletedCount", async () => { + const { DELETE } = + await import("@/app/api/measurements/by-external-ids/route"); + + await seedMeasurement({ userId: TEST_USER_ID, externalId: "uuid-del-1" }); + await seedMeasurement({ userId: TEST_USER_ID, externalId: "uuid-del-2" }); + await seedMeasurement({ + userId: TEST_USER_ID, + externalId: "uuid-keep-3", + }); + + const response = await DELETE( + makeRequest({ externalIds: ["uuid-del-1", "uuid-del-2"] }), + ); + expect(response.status).toBe(200); + const json = (await response.json()) as { data: { deletedCount: number } }; + expect(json.data.deletedCount).toBe(2); + + const remaining = await getPrismaClient().measurement.findMany({ + where: { userId: TEST_USER_ID }, + orderBy: { externalId: "asc" }, + }); + expect(remaining).toHaveLength(1); + expect(remaining[0]?.externalId).toBe("uuid-keep-3"); + }); + + it("never deletes rows owned by another user (cross-user 404 guard)", async () => { + const { DELETE } = + await import("@/app/api/measurements/by-external-ids/route"); + + // Two users own rows with the same externalId; deleting via the + // logged-in user must only remove that user's row. + await seedMeasurement({ userId: TEST_USER_ID, externalId: "uuid-shared" }); + await seedMeasurement({ + userId: OTHER_USER_ID, + externalId: "uuid-shared", + value: 70, + }); + + const response = await DELETE( + makeRequest({ externalIds: ["uuid-shared"] }), + ); + expect(response.status).toBe(200); + const json = (await response.json()) as { data: { deletedCount: number } }; + expect(json.data.deletedCount).toBe(1); + + const callerRows = await getPrismaClient().measurement.findMany({ + where: { userId: TEST_USER_ID }, + }); + expect(callerRows).toHaveLength(0); + + const otherRows = await getPrismaClient().measurement.findMany({ + where: { userId: OTHER_USER_ID }, + }); + expect(otherRows).toHaveLength(1); + expect(otherRows[0]?.externalId).toBe("uuid-shared"); + }); + + it("accepts an empty externalIds array as a no-op", async () => { + const { DELETE } = + await import("@/app/api/measurements/by-external-ids/route"); + + await seedMeasurement({ userId: TEST_USER_ID, externalId: "uuid-keep" }); + + const response = await DELETE(makeRequest({ externalIds: [] })); + expect(response.status).toBe(200); + const json = (await response.json()) as { data: { deletedCount: number } }; + expect(json.data.deletedCount).toBe(0); + + const stored = await getPrismaClient().measurement.findMany({ + where: { userId: TEST_USER_ID }, + }); + expect(stored).toHaveLength(1); + }); + + it("returns 422 with measurement.delete.too_large when batch exceeds the cap", async () => { + const { DELETE } = + await import("@/app/api/measurements/by-external-ids/route"); + + const externalIds: string[] = []; + for (let i = 0; i < 501; i++) { + externalIds.push(`uuid-cap-${i}`); + } + + const response = await DELETE(makeRequest({ externalIds })); + expect(response.status).toBe(422); + const json = (await response.json()) as { + error: string; + meta?: { errorCode?: string }; + }; + expect(json.error).toMatch(/500/); + expect(json.meta?.errorCode).toBe("measurement.delete.too_large"); + }); +});