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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 18:19:50 +02:00
1 parent 7022b698e7
commit dd7636e147
6 files changed
+143 -27

No files matched your search

+29 -25
View File
@@ -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,
+22
View File
@@ -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<string, unknown> = {};
+10
View File
@@ -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.
<p id={unitHintId} className="text-muted-foreground text-xs">
{t("labs.biomarker.form.unitLockedHint")}
</p>
) : null}
</div>
<div className="space-y-1.5">
<Label htmlFor="biomarker-panel">
+1
View File
@@ -76,6 +76,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"backup.section.missing",
"backup.upload.too_large",
],
biomarkers: ["biomarkers.unit.locked"],
"body-site": ["body-site.invalid"],
coach: [
"coach.budget.exceeded",
+1 -1
View File
@@ -155,7 +155,7 @@ export const biomarkerPaths: NonNullable<ZodOpenApiObject["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`.",
"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.",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
+80 -1
View File
@@ -3,7 +3,9 @@
* (`/api/biomarkers/{id}`) against a real Postgres.
*
* Covers the two detail-page mutations:
* - PUT adjusts the target range (and rejects an inverted window),
* - PUT adjusts the target range (and rejects an inverted window), and
* refuses a unit change while readings are linked, because readers
* label every reading with the marker's unit,
* - DELETE drops the marker together with its readings in one
* userId-narrowed transaction, leaving another user's marker untouched.
*/
@@ -141,6 +143,83 @@ describe("PUT /api/biomarkers/{id} — target range (real Postgres)", () => {
});
});
describe("PUT /api/biomarkers/{id} — unit lock (real Postgres)", () => {
it("refuses a unit change while readings are linked and changes nothing", async () => {
const { PUT } = await import("@/app/api/biomarkers/[id]/route");
const marker = await seedMarkerWithReadings(USER_ID, "Glucose");
const res = await PUT(
putReq(marker.id, { unit: "mmol/L", upperBound: 120 }),
params(marker.id),
);
expect(res.status).toBe(422);
const body = await res.json();
expect(body.data).toBeNull();
expect(body.meta).toMatchObject({
errorCode: "biomarkers.unit.locked",
readingCount: 2,
});
const row = await getPrismaClient().biomarker.findUniqueOrThrow({
where: { id: marker.id },
});
// Nothing of the request landed, not even the bound beside the unit.
expect(row.unit).toBe("mg/dL");
expect(row.upperBound).toBe(100);
});
it("counts a tombstoned reading, since an undo brings it back", async () => {
const { PUT } = await import("@/app/api/biomarkers/[id]/route");
const prisma = getPrismaClient();
const marker = await seedMarkerWithReadings(USER_ID, "Ferritin");
await prisma.labResult.updateMany({
where: { biomarkerId: marker.id },
data: { deletedAt: new Date() },
});
const res = await PUT(
putReq(marker.id, { unit: "ng/mL" }),
params(marker.id),
);
expect(res.status).toBe(422);
});
it("accepts the unchanged unit beside another edit", async () => {
const { PUT } = await import("@/app/api/biomarkers/[id]/route");
const marker = await seedMarkerWithReadings(USER_ID, "HbA1c");
const res = await PUT(
putReq(marker.id, { unit: "mg/dL", upperBound: 90 }),
params(marker.id),
);
expect(res.status).toBe(200);
const row = await getPrismaClient().biomarker.findUniqueOrThrow({
where: { id: marker.id },
});
expect(row.upperBound).toBe(90);
});
it("allows a unit change on a marker with no readings", async () => {
const { PUT } = await import("@/app/api/biomarkers/[id]/route");
const marker = await getPrismaClient().biomarker.create({
data: { userId: USER_ID, name: "TSH", unit: "mU/L" },
});
const res = await PUT(
putReq(marker.id, { unit: "µIU/mL" }),
params(marker.id),
);
expect(res.status).toBe(200);
const row = await getPrismaClient().biomarker.findUniqueOrThrow({
where: { id: marker.id },
});
expect(row.unit).toBe("µIU/mL");
});
});
describe("DELETE /api/biomarkers/{id} (real Postgres)", () => {
it("drops the marker together with its readings", async () => {
const { DELETE } = await import("@/app/api/biomarkers/[id]/route");