From 74986116cd3556820772ec44629a553732aa0da6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 16:30:17 +0200 Subject: [PATCH 1/2] feat(labs): accept optional OCR provenance on lab create The structured lab-create path (POST /api/labs) now accepts an optional `source` marker ("MANUAL" | "OCR"). The on-device-OCR ingestion path posts "OCR" so a reading captured from a phone-camera lab photo records its provenance; the raw image never touches the server on this path. Omitting the field reads as "MANUAL", so the existing hand-entry contract is unchanged. The value is narrowed by a closed Zod enum and assigned field-by-field, never spread from the body. Mirrors the "OCR" source the server-side /api/labs/ocr/commit path already stamps. Regenerate the OpenAPI spec for the widened request body. --- docs/api/openapi.yaml | 11 +++++++++-- src/app/api/labs/route.ts | 9 ++++++++- src/lib/openapi/routes/labs.ts | 4 ++-- src/lib/validations/labs.ts | 11 +++++++++++ 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 15d669cd8..f0241e651 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -7056,8 +7056,10 @@ paths: tags: - Labs summary: Record a lab result - description: Creates a single lab result for the caller. `source` is hardcoded MANUAL on this path. The optional note is - AES-256-GCM encrypted before write. Audits as `labResult.create`. + description: Creates a single lab result for the caller. The optional `source` marks provenance (`"MANUAL"` | `"OCR"`); + it defaults to `"MANUAL"` when omitted, so the legacy hand-entry contract is unchanged. The on-device-OCR client + posts `"OCR"` after the human confirms the parsed values — the raw image never touches the server on this path. + The optional note is AES-256-GCM encrypted before write. Audits as `labResult.create`. requestBody: required: true content: @@ -7104,6 +7106,11 @@ paths: maxLength: 2000 - type: string const: "" + source: + type: string + enum: + - MANUAL + - OCR required: - takenAt responses: diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 0c4821d02..0d2ea94c7 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -179,6 +179,7 @@ async function postLabResult(request: NextRequest) { referenceHigh, takenAt, note, + source, } = parsed.data; // v1.18.9 — a qualitative reading carries `valueText` ("negativ" / …) instead @@ -239,7 +240,11 @@ async function postLabResult(request: NextRequest) { referenceLow: biomarker.lowerBound, referenceHigh: biomarker.upperBound, takenAt, - source: "MANUAL", + // v1.25 (iOS #36) — provenance: the on-device-OCR path posts + // `source: "OCR"`; an omitted field stays "MANUAL" so the legacy + // hand-entry contract is unchanged. Narrowed to the closed enum by Zod, + // never spread from the body. + source: source ?? "MANUAL", noteEncrypted: note ? encryptNoteToBytes(note) : null, }, }); @@ -258,6 +263,8 @@ async function postLabResult(request: NextRequest) { // Whether the client picked a catalog marker (structured) vs the // free-text path that resolved-or-minted one server-side. structured: biomarkerId !== undefined, + // v1.25 (iOS #36) — provenance of the reading ("MANUAL" | "OCR"). + source: source ?? "MANUAL", }, }); diff --git a/src/lib/openapi/routes/labs.ts b/src/lib/openapi/routes/labs.ts index 5d20fc2d6..d2df9f671 100644 --- a/src/lib/openapi/routes/labs.ts +++ b/src/lib/openapi/routes/labs.ts @@ -21,7 +21,7 @@ import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; createLabResultSchema.meta({ id: "CreateLabResultRequest", description: - 'Record a single biomarker reading (HbA1c, LDL, ferritin, TSH, …). A reading is EITHER numeric (`value` + `unit`, optional reference bounds) OR qualitative (`valueText`, e.g. "negativ" / "positiv") — exactly one, never both, never neither. `analyte` + `unit` are free-form (a lab prints its own naming); a qualitative reading needs no unit / bounds. When both numeric bounds are present `referenceLow` must not exceed `referenceHigh`. `takenAt` is a backdatable ISO instant (no future, ≤ 50 years past). The optional `note` is encrypted at rest.', + 'Record a single biomarker reading (HbA1c, LDL, ferritin, TSH, …). A reading is EITHER numeric (`value` + `unit`, optional reference bounds) OR qualitative (`valueText`, e.g. "negativ" / "positiv") — exactly one, never both, never neither. `analyte` + `unit` are free-form (a lab prints its own naming); a qualitative reading needs no unit / bounds. When both numeric bounds are present `referenceLow` must not exceed `referenceHigh`. `takenAt` is a backdatable ISO instant (no future, ≤ 50 years past). The optional `note` is encrypted at rest. The optional `source` records provenance: `"OCR"` for an on-device-OCR capture (the raw image never reaches the server on this path), defaulting to `"MANUAL"` when omitted.', }); updateLabResultSchema.meta({ @@ -119,7 +119,7 @@ export const labsPaths: NonNullable = { tags: ["Labs"], summary: "Record a lab result", description: - "Creates a single lab result for the caller. `source` is hardcoded MANUAL on this path. The optional note is AES-256-GCM encrypted before write. Audits as `labResult.create`.", + 'Creates a single lab result for the caller. The optional `source` marks provenance (`"MANUAL"` | `"OCR"`); it defaults to `"MANUAL"` when omitted, so the legacy hand-entry contract is unchanged. The on-device-OCR client posts `"OCR"` after the human confirms the parsed values — the raw image never touches the server on this path. The optional note is AES-256-GCM encrypted before write. Audits as `labResult.create`.', requestBody: { required: true, content: { "application/json": { schema: createLabResultSchema } }, diff --git a/src/lib/validations/labs.ts b/src/lib/validations/labs.ts index 93bbd4359..7f0c53c8f 100644 --- a/src/lib/validations/labs.ts +++ b/src/lib/validations/labs.ts @@ -79,6 +79,16 @@ const biomarkerId = z.string().trim().min(1).max(64).optional(); */ const valueText = requiredText(120).optional(); +/** + * v1.25 (iOS #36) — optional provenance marker. The on-device-OCR ingestion + * path posts `source: "OCR"` so a reading captured from a phone-camera lab + * photo records its provenance; the raw image never touches the server on this + * path. Omitting the field reads as `"MANUAL"`, preserving the legacy + * hand-entry contract exactly. Mirrors the `source: "OCR"` the server-side + * `/api/labs/ocr/commit` path already stamps. + */ +const source = z.enum(["MANUAL", "OCR"]).optional(); + export const createLabResultSchema = z .object({ biomarkerId, @@ -93,6 +103,7 @@ export const createLabResultSchema = z referenceHigh, takenAt: takenAtField, note: optionalNote, + source, }) // Either a catalog link OR a free-text analyte must be present. .refine((d) => d.biomarkerId !== undefined || d.analyte !== undefined, { From 29db83ee5ae7565f28a861f9f1c3063d2dfeaea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 16:30:28 +0200 Subject: [PATCH 2/2] fix(measurements): scope external-id delete to app-minted HealthKit rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DELETE /api/measurements/by-external-ids now only tombstones rows the iOS client minted through its HealthKit ingestion path (source APPLE_HEALTH). An externalId the app never minted — an integration- or manually-sourced row that happens to collide on externalUUID — falls outside the predicate and is a no-op rather than a delete. Defence-in-depth so a foreign HealthKit sample with a colliding UUID cannot be tombstoned by the periodic reconciliation. The delete stays user-scoped as before. Add a route test proving a foreign-origin externalId is not deleted, and regenerate the OpenAPI spec for the narrowed contract. --- docs/api/openapi.yaml | 6 +- .../by-external-ids/__tests__/route.test.ts | 157 ++++++++++++++++++ .../api/measurements/by-external-ids/route.ts | 20 +++ src/lib/openapi/routes/measurements.ts | 2 +- 4 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 src/app/api/measurements/by-external-ids/__tests__/route.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f0241e651..30b9cbc17 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2363,8 +2363,10 @@ paths: 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. + description: "Removes the user's HealthKit-ingested measurement rows (source `APPLE_HEALTH`) whose externalId is in the + request list. Only app-minted rows are eligible: an externalId that maps to an integration- or manually-sourced + row (a colliding externalUUID) is a no-op, never a delete. Rows owned by another user are silently skipped + (cross-user 404 guard). Up to 500 externalIds per call." requestBody: required: true content: diff --git a/src/app/api/measurements/by-external-ids/__tests__/route.test.ts b/src/app/api/measurements/by-external-ids/__tests__/route.test.ts new file mode 100644 index 000000000..c618a6bc8 --- /dev/null +++ b/src/app/api/measurements/by-external-ids/__tests__/route.test.ts @@ -0,0 +1,157 @@ +/** + * DELETE /api/measurements/by-external-ids — iOS HealthKit deletion-sync. + * + * Asserts the origin-scoped soft-delete contract (v1.25, iOS #35): the + * reconciliation only tombstones rows the app minted through its HealthKit + * ingestion path (`source = APPLE_HEALTH`). A foreign-origin externalId — one + * that collides with an integration- or manually-sourced row — is excluded by + * the source predicate and is a silent no-op, never a delete. Cross-user and + * idempotency guards stay as before. + */ +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/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 { + 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 { DELETE } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +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 delReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/measurements/by-external-ids", { + method: "DELETE", + 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(recomputeBucketsForMeasurement).mockResolvedValue(undefined); + vi.mocked(invalidateStatusInsightsForTypes).mockResolvedValue(undefined); +}); + +describe("DELETE /api/measurements/by-external-ids", () => { + it("scopes both the pre-fetch and the soft-delete to the app-minted APPLE_HEALTH source", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { type: "WEIGHT", measuredAt: new Date("2026-01-01T08:00:00Z") }, + ] as never); + vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ + count: 1, + } as never); + + const res = await DELETE(delReq({ externalIds: ["uuid-aaa"] })); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { deletedCount: number } }; + expect(body.data.deletedCount).toBe(1); + + const findWhere = vi.mocked(prisma.measurement.findMany).mock.calls[0]![0]! + .where; + expect(findWhere).toMatchObject({ + userId: "user-1", + source: "APPLE_HEALTH", + externalId: { in: ["uuid-aaa"] }, + deletedAt: null, + }); + + const updateWhere = vi.mocked(prisma.measurement.updateMany).mock + .calls[0]![0].where; + expect(updateWhere).toMatchObject({ + userId: "user-1", + source: "APPLE_HEALTH", + externalId: { in: ["uuid-aaa"] }, + deletedAt: null, + }); + }); + + it("is a no-op for a foreign-origin externalId that collides on a non-app-minted row", async () => { + // The caller's reconciliation list carries an externalId that, on the + // server, maps to a WITHINGS-sourced row (a colliding externalUUID). The + // `source = APPLE_HEALTH` predicate excludes it, so the pre-fetch and the + // soft-delete both match zero rows. + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ + count: 0, + } as never); + + const res = await DELETE(delReq({ externalIds: ["withings-collision"] })); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { deletedCount: number } }; + expect(body.data.deletedCount).toBe(0); + + // The delete was still source-scoped (the foreign row was never eligible). + const updateWhere = vi.mocked(prisma.measurement.updateMany).mock + .calls[0]![0].where; + expect(updateWhere).toMatchObject({ source: "APPLE_HEALTH" }); + + // Zero deletions → no cache eviction, no rollup recompute, no insight bust. + expect(invalidateUserMeasurements).not.toHaveBeenCalled(); + expect(recomputeBucketsForMeasurement).not.toHaveBeenCalled(); + expect(invalidateStatusInsightsForTypes).not.toHaveBeenCalled(); + }); + + it("returns deletedCount 0 with 200 for an empty externalIds array", async () => { + const res = await DELETE(delReq({ externalIds: [] })); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { deletedCount: number } }; + expect(body.data.deletedCount).toBe(0); + expect(prisma.measurement.updateMany).not.toHaveBeenCalled(); + }); + + it("rejects a batch over the 500-id cap with 422", async () => { + const externalIds = Array.from({ length: 501 }, (_, i) => `uuid-${i}`); + const res = await DELETE(delReq({ externalIds })); + expect(res.status).toBe(422); + expect(prisma.measurement.updateMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/measurements/by-external-ids/route.ts b/src/app/api/measurements/by-external-ids/route.ts index 9c07d6a53..9914cb12b 100644 --- a/src/app/api/measurements/by-external-ids/route.ts +++ b/src/app/api/measurements/by-external-ids/route.ts @@ -10,6 +10,13 @@ * rather than removing the row, so deletions surface as tombstones on the * `/api/sync/changes` delta feed). * + * Origin scope (v1.25, iOS #35): the delete only touches rows the iOS + * client minted through its HealthKit ingestion path (`source = + * APPLE_HEALTH`). An externalId the app never minted — an integration- or + * manually-sourced row that happens to collide on externalUUID — is a + * no-op, never a delete. Defence-in-depth so a foreign HealthKit sample + * with a colliding UUID cannot be tombstoned by this reconciliation. + * * Idempotency contract: * - Cross-user safety: rows owned by another user are silently skipped * (Prisma's `updateMany` with `userId` in the where-clause guarantees @@ -105,9 +112,21 @@ async function deleteByExternalIds(request: NextRequest): Promise { // tombstoned by this call. We pre-fetch the (type, measuredAt) tuples // of the live matches so the rollup recompute below knows which // buckets to refresh. + // + // v1.25 (iOS #35) — scope to the app's HealthKit ingestion source + // (`APPLE_HEALTH`). This reconciliation removes HealthKit samples the + // iOS client minted; only `source = APPLE_HEALTH` rows are app-minted on + // this path. Defence-in-depth against a colliding externalId: an + // integration- or manually-sourced row (WITHINGS / OURA / MANUAL / …) + // that happens to share an externalUUID with a now-absent HealthKit + // sample is NOT app-minted, so it falls outside the predicate and the + // delete is a no-op for it rather than tombstoning a foreign-origin row. + const APP_MINTED_SOURCE = "APPLE_HEALTH" as const; + const affectedRows = await prisma.measurement.findMany({ where: { userId: user.id, + source: APP_MINTED_SOURCE, externalId: { in: externalIds }, deletedAt: null, }, @@ -117,6 +136,7 @@ async function deleteByExternalIds(request: NextRequest): Promise { const result = await prisma.measurement.updateMany({ where: { userId: user.id, + source: APP_MINTED_SOURCE, externalId: { in: externalIds }, deletedAt: null, }, diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 650d157f8..09aab36be 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -638,7 +638,7 @@ export const measurementPaths: NonNullable = { 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.", + "Removes the user's HealthKit-ingested measurement rows (source `APPLE_HEALTH`) whose externalId is in the request list. Only app-minted rows are eligible: an externalId that maps to an integration- or manually-sourced row (a colliding externalUUID) is a no-op, never a delete. Rows owned by another user are silently skipped (cross-user 404 guard). Up to 500 externalIds per call.", requestBody: { required: true, content: {