From e605fe0ae8896f357470142fa905b6b085656ffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 2 Nov 2026 20:28:35 +0100 Subject: [PATCH] docs(api): describe the dimensionless lab unit on every lab unit field The unit on lab results, biomarkers, the scanned-report commit and the document fact edit now states that "1" means the value has no unit, that it matches only itself, and that an empty unit stays refused. The lab CSV import description names the 1 in the unit column. Refs #1313 --- docs/api/openapi.yaml | 59 ++++++++++++++++++++++-- src/lib/openapi/routes/biomarkers.ts | 4 +- src/lib/openapi/routes/import.ts | 2 +- src/lib/openapi/routes/labs.ts | 4 +- src/lib/validations/biomarkers.ts | 6 ++- src/lib/validations/inbound-documents.ts | 4 +- src/lib/validations/labs-ocr.ts | 9 +++- src/lib/validations/labs.ts | 5 +- 8 files changed, 79 insertions(+), 14 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 202e7ec9d..4785a7a6a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -15875,10 +15875,11 @@ paths: checks an hour, the import itself the shared 5-an-hour `import:` bucket. Owner only; refused with `module.disabled` when the module is switched off. An unknown query parameter is refused with 422, so a misspelt `dryRun` never writes. Each row's name resolves to the account's marker the way a scanned report's rows do - (name, alias, catalog name); a new analyte mints one marker from its first row. A numeric result needs a unit; - one in another unit is converted when the conversion is known and keeps the printed value beside it, otherwise - the row is refused with `unit_mismatch`. Any other text in the value column is a qualitative result. A date - without a time is stored as that calendar date. At most 20 000 data rows." + (name, alias, catalog name); a new analyte mints one marker from its first row. A numeric result needs a unit: + an empty unit cell is refused with `missing_unit`, and `1` (the UCUM unity) states a value without a unit, such + as a ratio or INR. A result in another unit is converted when the conversion is known and keeps the printed + value beside it, otherwise the row is refused with `unit_mismatch`. Any other text in the value column is a + qualitative result. A date without a time is stored as that calendar date. At most 20 000 data rows." parameters: - in: query name: dryRun @@ -34146,6 +34147,11 @@ components: minLength: 1 maxLength: 120 unit: + description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means + the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a + unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real + unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is + never read as `"1"` and stays refused.' type: string minLength: 1 maxLength: 40 @@ -34215,6 +34221,11 @@ components: minLength: 1 maxLength: 120 unit: + description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means + the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a + unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real + unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is + never read as `"1"` and stays refused.' type: string minLength: 1 maxLength: 40 @@ -34309,6 +34320,11 @@ components: minLength: 1 maxLength: 120 unit: + description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means + the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice + and a unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker + kept in a real unit and the other way round. Clients show nothing beside such a value. An empty unit + on a numeric value is never read as `"1"` and stays refused.' type: string minLength: 1 maxLength: 40 @@ -34363,6 +34379,11 @@ components: type: string minLength: 1 maxLength: 40 + description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means + the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a + unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real + unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is + never read as `"1"` and stays refused.' lowerBound: type: number upperBound: @@ -34392,6 +34413,11 @@ components: minLength: 1 maxLength: 120 unit: + description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means + the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a + unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real + unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is + never read as `"1"` and stays refused.' type: string minLength: 1 maxLength: 40 @@ -35759,6 +35785,11 @@ components: maxLength: 300 - type: "null" unit: + description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means + the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and + a unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in + a real unit and the other way round. Clients show nothing beside such a value. An empty unit on a + numeric value is never read as `"1"` and stays refused.' anyOf: - type: string maxLength: 80 @@ -75275,6 +75306,11 @@ components: - "null" unit: type: string + description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means + the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a + unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real + unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is + never read as `"1"` and stays refused.' converted: type: boolean sourceValue: @@ -75524,6 +75560,11 @@ components: - "null" unit: type: string + description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means + the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a + unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real + unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is + never read as `"1"` and stays refused.' converted: type: boolean sourceValue: @@ -76503,6 +76544,11 @@ components: type: string unit: type: string + description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means + the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a + unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real + unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is + never read as `"1"` and stays refused.' lowerBound: type: - number @@ -76602,6 +76648,11 @@ components: type: string unit: type: string + description: 'The unit the value is in, free-form as the report prints it (e.g. `mg/dL`). `"1"`, the UCUM unity, means + the value has no unit: a ratio or an index such as INR or cholesterol/HDL. It is an explicit choice and a + unit of its own: it matches only `"1"`, so a reading without a unit is refused under a marker kept in a real + unit and the other way round. Clients show nothing beside such a value. An empty unit on a numeric value is + never read as `"1"` and stays refused.' lowerBound: type: - number diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts index fc850c598..893724720 100644 --- a/src/lib/openapi/routes/biomarkers.ts +++ b/src/lib/openapi/routes/biomarkers.ts @@ -14,6 +14,8 @@ import type { ZodOpenApiObject } from "zod-openapi"; import { z } from "zod/v4"; +import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless"; + import { createBiomarkerSchema, updateBiomarkerSchema, @@ -54,7 +56,7 @@ const biomarkerRow = z .object({ id: z.string(), name: z.string(), - unit: z.string(), + unit: z.string().describe(LAB_UNIT_DESCRIPTION), lowerBound: z.number().nullable(), upperBound: z.number().nullable(), panel: z.string().nullable(), diff --git a/src/lib/openapi/routes/import.ts b/src/lib/openapi/routes/import.ts index c22baff64..053cb10a8 100644 --- a/src/lib/openapi/routes/import.ts +++ b/src/lib/openapi/routes/import.ts @@ -553,7 +553,7 @@ export const importPaths: NonNullable = { post: { tags: ["Import"], summary: "Import lab results from CSV", - description: `${RECORD_IMPORT_COMMON} Each row's name resolves to the account's marker the way a scanned report's rows do (name, alias, catalog name); a new analyte mints one marker from its first row. A numeric result needs a unit; one in another unit is converted when the conversion is known and keeps the printed value beside it, otherwise the row is refused with \`unit_mismatch\`. Any other text in the value column is a qualitative result. A date without a time is stored as that calendar date. At most 20 000 data rows.`, + description: `${RECORD_IMPORT_COMMON} Each row's name resolves to the account's marker the way a scanned report's rows do (name, alias, catalog name); a new analyte mints one marker from its first row. A numeric result needs a unit: an empty unit cell is refused with \`missing_unit\`, and \`1\` (the UCUM unity) states a value without a unit, such as a ratio or INR. A result in another unit is converted when the conversion is known and keeps the printed value beside it, otherwise the row is refused with \`unit_mismatch\`. Any other text in the value column is a qualitative result. A date without a time is stored as that calendar date. At most 20 000 data rows.`, requestParams: { query: labsCsvQuerySchema }, requestBody: { required: true, diff --git a/src/lib/openapi/routes/labs.ts b/src/lib/openapi/routes/labs.ts index 8e7527928..279011802 100644 --- a/src/lib/openapi/routes/labs.ts +++ b/src/lib/openapi/routes/labs.ts @@ -11,6 +11,8 @@ import { withRelated } from "./relation-links"; import type { ZodOpenApiObject } from "zod-openapi"; import { z } from "zod/v4"; +import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless"; + import { createLabResultSchema, labOverviewQuerySchema, @@ -71,7 +73,7 @@ const labResultRow = z // exactly one of the two is non-null per row. value: z.number().nullable(), valueText: z.string().nullable(), - unit: z.string(), + unit: z.string().describe(LAB_UNIT_DESCRIPTION), converted: z.boolean(), sourceValue: z.number().nullable(), sourceUnit: z.string().nullable(), diff --git a/src/lib/validations/biomarkers.ts b/src/lib/validations/biomarkers.ts index fa42d8de9..2841b3f9e 100644 --- a/src/lib/validations/biomarkers.ts +++ b/src/lib/validations/biomarkers.ts @@ -15,6 +15,8 @@ */ import { z } from "zod/v4"; +import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless"; + const requiredText = (max: number) => z.string().trim().min(1).max(max); const lowerBound = z.number().finite().optional(); @@ -40,7 +42,7 @@ const optionalPanel = z export const createBiomarkerSchema = z .object({ name: requiredText(120), - unit: requiredText(40), + unit: requiredText(40).describe(LAB_UNIT_DESCRIPTION), lowerBound, upperBound, context: optionalContext, @@ -67,7 +69,7 @@ export type CreateBiomarkerInput = z.infer; export const updateBiomarkerSchema = z .object({ name: requiredText(120).optional(), - unit: requiredText(40).optional(), + unit: requiredText(40).optional().describe(LAB_UNIT_DESCRIPTION), lowerBound: lowerBound.or(z.null()), upperBound: upperBound.or(z.null()), context: optionalContext.or(z.null()), diff --git a/src/lib/validations/inbound-documents.ts b/src/lib/validations/inbound-documents.ts index a66239531..d6c336c26 100644 --- a/src/lib/validations/inbound-documents.ts +++ b/src/lib/validations/inbound-documents.ts @@ -25,6 +25,7 @@ */ import { z } from "zod/v4"; import { locales } from "@/lib/i18n/config"; +import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless"; import { DOCUMENT_BULK_MAX_IDS, DOCUMENT_CONTENT_INDEX_SOURCES, @@ -34,7 +35,6 @@ import { type DocumentContentIndexSourceValue, type InboundDocumentKindValue, } from "@/lib/documents/inbound-constants"; - import type { AiCapabilityState } from "@/lib/ai/capabilities/types"; import { OCR_MAX_ROWS } from "@/lib/validations/labs-ocr"; @@ -443,7 +443,7 @@ const observationEditSchema = z codeSystem: optCode, value: z.number().finite().nullable().optional(), valueText: optText(300), - unit: optText(80), + unit: optText(80).describe(LAB_UNIT_DESCRIPTION), referenceLow: z.number().finite().nullable().optional(), referenceHigh: z.number().finite().nullable().optional(), referenceText: optText(120), diff --git a/src/lib/validations/labs-ocr.ts b/src/lib/validations/labs-ocr.ts index 9e65d9a1c..8d92cafbc 100644 --- a/src/lib/validations/labs-ocr.ts +++ b/src/lib/validations/labs-ocr.ts @@ -19,6 +19,7 @@ import type { AiCapabilityState } from "@/lib/ai/capabilities/types"; import { z } from "zod/v4"; +import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless"; import type { WrittenOutcome } from "@/lib/outcome/written-outcome"; import { validateEntryInstant } from "@/lib/validations/entry-instant"; @@ -257,7 +258,13 @@ export const ocrCommitRowSchema = z .or(z.literal("").transform(() => undefined)), value: z.number().finite().optional(), valueText: z.string().trim().min(1).max(OCR_VALUE_TEXT_MAX).optional(), - unit: z.string().trim().min(1).max(OCR_UNIT_MAX).optional(), + unit: z + .string() + .trim() + .min(1) + .max(OCR_UNIT_MAX) + .optional() + .describe(LAB_UNIT_DESCRIPTION), referenceLow, referenceHigh, /** diff --git a/src/lib/validations/labs.ts b/src/lib/validations/labs.ts index d19a4bfe5..8879d2032 100644 --- a/src/lib/validations/labs.ts +++ b/src/lib/validations/labs.ts @@ -14,6 +14,7 @@ */ import { z } from "zod/v4"; +import { LAB_UNIT_DESCRIPTION } from "@/lib/labs/dimensionless"; import { SOURCE_REFERENCE_TEXT_MAX } from "@/lib/labs/parse-reference-range"; import { validateEntryInstant } from "@/lib/validations/entry-instant"; import { isoDateTime } from "@/lib/validations/datetime"; @@ -123,7 +124,7 @@ export const createLabResultSchema = z // (`value`) OR qualitative (`valueText`), never both, never neither. value: z.number().finite().optional(), valueText, - unit: requiredText(40).optional(), + unit: requiredText(40).optional().describe(LAB_UNIT_DESCRIPTION), referenceLow, referenceHigh, sourceReferenceLow, @@ -208,7 +209,7 @@ export const updateLabResultSchema = z // row's type (numeric ↔ qualitative) is intentionally not supported here — // delete and re-add — so an omitted key leaves the row's existing type. valueText: requiredText(120).optional(), - unit: requiredText(40).optional(), + unit: requiredText(40).optional().describe(LAB_UNIT_DESCRIPTION), referenceLow: referenceLow.or(z.null()), referenceHigh: referenceHigh.or(z.null()), sourceReferenceLow: sourceReferenceLow.or(z.null()),