Files
HealthLog/src/lib/openapi/routes/labs.ts
T
Marc-André Bombeck 74986116cd 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.
2026-06-28 16:30:17 +02:00

239 lines
9.0 KiB
TypeScript

/**
* OpenAPI route table for the structured lab-result store (`/api/labs`).
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request
* bodies + query reuse the runtime Zod schemas from `@/lib/validations/labs`
* so the wire contract stays single-source. Response shapes are declared
* here (the route serialises a derived `rangeStatus` + `hasNote` the input
* schema doesn't carry).
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import {
createLabResultSchema,
listLabResultsSchema,
updateLabResultSchema,
} from "@/lib/validations/labs";
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. 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({
id: "UpdateLabResultRequest",
description:
"Partial edit of a lab result. An omitted key leaves the column untouched; an explicit `null` on `panel` / `note` / a reference bound clears it. Edit the matching value field for the row's type — `value` for a numeric row, `valueText` for a qualitative one; a request must not carry both, and a cross-type edit (the wrong field for the row) is rejected.",
});
listLabResultsSchema.meta({
id: "ListLabResultsQuery",
description:
"Query params for the lab-result list: optional `analyte` (exact) + `panel` filters, an inclusive `from`/`to` date range, and `limit` (≤ 500) / `offset` pagination. Defaults to `takenAt` DESC.",
});
const rangeStatusEnum = z.enum(["in-range", "below", "above", "unknown"]).meta({
id: "LabReferenceRangeStatus",
description:
"Server-computed, NEUTRAL reference-range verdict. `unknown` when the lab reported no usable bounds. Inclusive bounds: a value on the limit reads in-range. The badge that renders this must stay calm and informative — not an alarming red.",
});
const labResultRow = z
.object({
id: z.string(),
biomarkerId: z.string().nullable(),
panel: z.string().nullable(),
analyte: z.string(),
// v1.18.9 — `value` is null for a qualitative reading (`valueText` set);
// exactly one of the two is non-null per row.
value: z.number().nullable(),
valueText: z.string().nullable(),
unit: z.string(),
referenceLow: z.number().nullable(),
referenceHigh: z.number().nullable(),
takenAt: z.string(),
source: z.string(),
hasNote: z.boolean(),
rangeStatus: rangeStatusEnum,
createdAt: z.string(),
updatedAt: z.string(),
})
.meta({
id: "LabResult",
description:
'A stored lab result. Exactly one of `value` (numeric) or `valueText` (qualitative, e.g. "negativ") is non-null; `rangeStatus` is always `unknown` for a qualitative row (no number to classify). `biomarkerId` links the user-scoped catalog marker (null for legacy free-text rows); when linked, `analyte` / `unit` / `referenceLow` / `referenceHigh` are RESOLVED server-side from the Biomarker — the client renders them and never recomputes the range. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the single-resource GET returns the decrypted `note`.',
});
const labResultDetail = labResultRow
.omit({ hasNote: true })
.extend({ note: z.string().nullable() })
.meta({
id: "LabResultDetail",
description:
"Single lab result including its decrypted free-text `note` (or null).",
});
const listResponse = z
.object({
results: z.array(labResultRow),
meta: z.object({
total: z.number(),
limit: z.number(),
offset: z.number(),
}),
})
.meta({ id: "ListLabResultsResponse" });
const notFound = {
"404": {
description: "Lab result not found (or owned by another user).",
content: { "application/json": { schema: errorEnvelope } },
},
};
export const labsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/labs": {
get: {
tags: ["Labs"],
summary: "List the caller's lab results",
description:
"Returns the caller's live (non-deleted) lab results, newest first, with optional analyte / panel / date-range filters. Each row carries a server-computed neutral `rangeStatus` and a `hasNote` flag (the encrypted note itself is not echoed).",
requestParams: { query: listLabResultsSchema },
responses: {
"200": {
description: "Lab-result list.",
content: {
"application/json": {
schema: dataEnvelope(listResponse, "ListLabResultsEnvelope"),
},
},
},
...stdResponses,
},
},
post: {
tags: ["Labs"],
summary: "Record a lab result",
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: { "application/json": { schema: createLabResultSchema } },
},
responses: {
"201": {
description: "Created lab result.",
content: {
"application/json": {
schema: dataEnvelope(labResultRow, "CreateLabResultResponse"),
},
},
},
...stdResponses,
},
},
},
"/api/labs/{id}": {
get: {
tags: ["Labs"],
summary: "Fetch a single lab result",
description:
"Returns the lab result including its decrypted `note`. Cross-user rows surface as 404.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Lab-result detail.",
content: {
"application/json": {
schema: dataEnvelope(labResultDetail, "GetLabResultResponse"),
},
},
},
...notFound,
...stdResponses,
},
},
put: {
tags: ["Labs"],
summary: "Edit a lab result",
description:
"Partial edit; omitted fields are untouched, an explicit null clears `panel` / `note` / a reference bound. Audits as `labResult.update`.",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
content: { "application/json": { schema: updateLabResultSchema } },
},
responses: {
"200": {
description: "Updated lab result.",
content: {
"application/json": {
schema: dataEnvelope(labResultRow, "UpdateLabResultResponse"),
},
},
},
...notFound,
...stdResponses,
},
},
delete: {
tags: ["Labs"],
summary: "Delete a lab result",
description:
"Soft-deletes the lab result (stamps `deletedAt`). Idempotent. Audits as `labResult.delete`.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Deletion succeeded.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ deleted: z.boolean() }),
"DeleteLabResultResponse",
),
},
},
},
...notFound,
...stdResponses,
},
},
},
"/api/labs/restore": {
post: {
tags: ["Labs"],
summary: "Restore soft-deleted lab results",
description:
"Un-tombstones 1..200 of the caller's soft-deleted lab results in one pass (the delete-Undo affordance). A foreign / live id is a silent no-op. Audits via the wide-event ledger.",
requestBody: {
required: true,
content: {
"application/json": {
schema: z
.object({ ids: z.array(z.string()).min(1).max(200) })
.meta({ id: "RestoreLabResultsRequest" }),
},
},
},
responses: {
"200": {
description: "Restore count.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ restored: z.number() }),
"RestoreLabResultsResponse",
),
},
},
},
...stdResponses,
},
},
},
};