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"); + }); +});