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] 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, {