From dd7636e14764993ed6fe7ab5ee049e3fac6180cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 18:19:50 +0200 Subject: [PATCH] feat(labs): refuse a biomarker unit change while readings are linked Readers label every linked reading with the marker's unit, so changing it relabelled stored numbers without converting them. PUT /api/biomarkers/{id} now answers 422 biomarkers.unit.locked with meta.readingCount when the unit differs and any reading (tombstoned ones included) is linked. The marker editor says so under the unit field. --- docs/api/openapi.yaml | 54 ++++++++------- src/app/api/biomarkers/[id]/route.ts | 22 ++++++ src/components/labs/biomarker-form.tsx | 10 +++ src/lib/openapi/error-codes.ts | 1 + src/lib/openapi/routes/biomarkers.ts | 2 +- tests/integration/biomarker-detail.test.ts | 81 +++++++++++++++++++++- 6 files changed, 143 insertions(+), 27 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d066ed6f3..4e702ad03 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -14369,8 +14369,11 @@ paths: tags: - Labs summary: Edit a biomarker - description: Partial edit; omitted fields are untouched, an explicit null clears `context` / `panel` / a bound. Audits - as `biomarker.update`. + description: "Partial edit; omitted fields are untouched, an explicit null clears `context` / `panel` / a bound. Audits + as `biomarker.update`. Since v1.40.0 a `unit` different from the stored one is refused with 422 + `biomarkers.unit.locked` while any reading is linked to the marker (`meta.readingCount` carries how many): + readers label every linked reading with the marker's unit, so a change would relabel stored numbers without + converting them. Sending the current unit, or changing it on a marker with no readings, is accepted." parameters: - in: path name: id @@ -31719,29 +31722,30 @@ components: auth.reproof.failed, auth.reproof.required, auth.reproof.too_weak, auth.scope.insufficient, auth.stepup.required, auth.token.expired, auth.token.invalid; backup — backup.foreign_reference, backup.key.missing, backup.payload.undecryptable, backup.restore.active, backup.section.missing, - backup.upload.too_large; body-site — body-site.invalid; coach — coach.budget.exceeded, - coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict, - coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, - coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid, - coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; - consent — consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, - cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, - cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; - cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — - disable-coach.body.invalid_json; documents — documents.chat.conversationNotFound, - documents.chat.invalid, documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, - documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound, - documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound, - documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, - documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery, - documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notFound, - documents.inbound.notIndexed, documents.inbound.pdfNeedsAnthropic, - documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited, - documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, - documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, - documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly, - documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected, - documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed, + backup.upload.too_large; biomarkers — biomarkers.unit.locked; body-site — body-site.invalid; coach — + coach.budget.exceeded, coach.conversation.invalidTitle, coach.conversation.notFound, + coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, + coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, + coach.fenced.invalid, coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, + coach-prefs.body.invalid_shape; consent — consent.ai.required; cycle — cycle.bulk.invalid, + cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, cycle.cycles.invalid, + cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, cycle.period.invalid, + cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json, + cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; documents — + documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed, + documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded, + documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound, + documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending, + documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType, + documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate, + documents.inbound.localOcrDisabled, documents.inbound.notFound, documents.inbound.notIndexed, + documents.inbound.pdfNeedsAnthropic, documents.inbound.providerUnsupported, + documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate, + documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy, + documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused, + documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress, + documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound, + documents.sources.organizationRequired, documents.sources.originNotAllowed, documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected, documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable, documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid, diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts index 45cc9f98a..a17d0b015 100644 --- a/src/app/api/biomarkers/[id]/route.ts +++ b/src/app/api/biomarkers/[id]/route.ts @@ -143,6 +143,28 @@ export const PUT = apiHandler( } } + // A marker's unit labels every reading linked to it: readers resolve the + // unit from the marker, and the stored numbers are in the old unit. A + // change while readings exist would relabel history without converting a + // single value, so it is refused. Tombstoned readings count too, since an + // undo brings them back under the new label. + if (d.unit !== undefined && d.unit !== existing.unit) { + const readingCount = await prisma.labResult.count({ + where: { userId: user.id, biomarkerId: id }, + }); + if (readingCount > 0) { + annotate({ + action: { name: "labs.biomarker.update.unit-locked" }, + meta: { biomarkerId: id, readingCount }, + }); + return apiError( + "The unit of a biomarker with readings cannot be changed", + 422, + { errorCode: "biomarkers.unit.locked", readingCount }, + ); + } + } + // Build `data` field-by-field. `undefined` → leave the column untouched; // an explicit `null` on `context` / `panel` / a bound clears it. const data: Record = {}; diff --git a/src/components/labs/biomarker-form.tsx b/src/components/labs/biomarker-form.tsx index 220c6b927..51503c242 100644 --- a/src/components/labs/biomarker-form.tsx +++ b/src/components/labs/biomarker-form.tsx @@ -70,6 +70,7 @@ export function BiomarkerForm({ }: BiomarkerFormProps) { const { t } = useTranslations(); const formId = useId(); + const unitHintId = `${formId}-unit-hint`; const [name, setName] = useState(existing?.name ?? ""); const [unit, setUnit] = useState(existing?.unit ?? ""); @@ -225,7 +226,16 @@ export function BiomarkerForm({ placeholder={t("labs.biomarker.form.unitPlaceholder")} maxLength={40} required + aria-describedby={existing ? unitHintId : undefined} /> + {existing ? ( + // The server refuses a unit change while readings are linked: + // every reading is shown in the marker's unit, so a new unit + // would relabel stored numbers without converting them. +

+ {t("labs.biomarker.form.unitLockedHint")} +

+ ) : null}