feat(labs): add a hidden flag to the biomarker catalog

Add a `hidden` boolean to the Biomarker model (migration
0195_biomarker_hidden) so a marker the user no longer needs can be
soft-removed instead of deleted: it leaves the active catalog and the
lab-entry pickers but keeps its readings and canonical unit/range.

Thread `hidden` through the DTO, the update schema, the PUT handler
(field-by-field), and the list endpoint (visible markers first). Regen
the OpenAPI contract.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 12:48:48 +02:00
1 parent 42d49953f5
commit e097e57c84
9 files changed
+54 -4

No files matched your search

+8 -1
View File
@@ -6741,6 +6741,8 @@ paths:
- type: string
const: ""
- type: "null"
hidden:
type: boolean
responses:
"200":
description: Updated biomarker.
@@ -20212,6 +20214,8 @@ components:
anyOf:
- type: string
- type: "null"
hidden:
type: boolean
createdAt:
type: string
updatedAt:
@@ -20225,11 +20229,14 @@ components:
- panel
- hasContext
- context
- hidden
- createdAt
- updatedAt
additionalProperties: false
description: A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its
presence. A `LabResult` linking this marker resolves its unit + reference bounds from here.
presence. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it
(its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds
from here.
CreateBiomarkerResponse:
type: object
properties:
@@ -0,0 +1,9 @@
-- v1.22.0 — Biomarker catalog: per-marker "hidden" flag.
--
-- A marker the user no longer needs is hidden rather than deleted: it leaves
-- the active catalog list and the lab-entry pickers, but its readings and its
-- canonical unit/reference-range definition survive (unhide restores it). A
-- plain additive boolean column with a server-side default; no existing row is
-- touched (every back-filled row defaults to visible). `IF NOT EXISTS` makes
-- the rerun safe.
ALTER TABLE "biomarkers" ADD COLUMN IF NOT EXISTS "hidden" BOOLEAN NOT NULL DEFAULT false;
+4
View File
@@ -3657,6 +3657,10 @@ model Biomarker {
/// Optional panel grouping moved off the per-entry row, e.g.
/// "Großes Blutbild". NULL = standalone marker.
panel String?
/// A marker the user no longer needs: dropped from the active catalog list
/// and from the lab-entry pickers, but never deleted — its readings and
/// canonical unit/range definition survive.
hidden Boolean @default(false) @map("hidden")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
+3
View File
@@ -39,6 +39,7 @@ function serialiseBiomarker(row: {
lowerBound: number | null;
upperBound: number | null;
panel: string | null;
hidden: boolean;
contextEncrypted: Uint8Array | null;
createdAt: Date;
updatedAt: Date;
@@ -54,6 +55,7 @@ function serialiseBiomarker(row: {
context: row.contextEncrypted
? decryptContextFromBytes(row.contextEncrypted)
: null,
hidden: row.hidden,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
@@ -137,6 +139,7 @@ export const PUT = apiHandler(
if (d.lowerBound !== undefined) data.lowerBound = d.lowerBound;
if (d.upperBound !== undefined) data.upperBound = d.upperBound;
if (d.panel !== undefined) data.panel = d.panel;
if (d.hidden !== undefined) data.hidden = d.hidden;
// Inverted-range guard for a PARTIAL bound update. The schema refine only
// fires when both bounds arrive together; moving a single bound past the
+5 -1
View File
@@ -50,6 +50,7 @@ function serialiseBiomarker(row: {
lowerBound: number | null;
upperBound: number | null;
panel: string | null;
hidden: boolean;
contextEncrypted: Uint8Array | null;
createdAt: Date;
updatedAt: Date;
@@ -63,6 +64,7 @@ function serialiseBiomarker(row: {
panel: row.panel,
hasContext: row.contextEncrypted !== null,
context: decryptContextSoft(row.contextEncrypted),
hidden: row.hidden,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
@@ -71,9 +73,11 @@ function serialiseBiomarker(row: {
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// Visible markers first, then hidden — the manager renders them in two
// sections and the lab-entry pickers drop the hidden tail client-side.
const rows = await prisma.biomarker.findMany({
where: { userId: user.id },
orderBy: [{ name: "asc" }],
orderBy: [{ hidden: "asc" }, { name: "asc" }],
});
annotate({
+2
View File
@@ -42,6 +42,8 @@ export interface BiomarkerDto {
panel: string | null;
hasContext: boolean;
context: string | null;
/** v1.22 — a hidden marker drops from the active list + lab-entry pickers. */
hidden: boolean;
createdAt: string;
updatedAt: string;
}
+3 -2
View File
@@ -30,7 +30,7 @@ createBiomarkerSchema.meta({
updateBiomarkerSchema.meta({
id: "UpdateBiomarkerRequest",
description:
"Partial edit of a biomarker. An omitted key leaves the column untouched; an explicit `null` on `context` / `panel` / a bound clears it. A rename that collides with another of the caller's markers is rejected 409.",
"Partial edit of a biomarker. An omitted key leaves the column untouched; an explicit `null` on `context` / `panel` / a bound clears it. Set `hidden` to drop / restore the marker in the active list + lab-entry pickers. A rename that collides with another of the caller's markers is rejected 409.",
});
const biomarkerRow = z
@@ -43,13 +43,14 @@ const biomarkerRow = z
panel: z.string().nullable(),
hasContext: z.boolean(),
context: z.string().nullable(),
hidden: z.boolean(),
createdAt: z.string(),
updatedAt: z.string(),
})
.meta({
id: "Biomarker",
description:
"A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. A `LabResult` linking this marker resolves its unit + reference bounds from here.",
"A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds from here.",
});
const listResponse = z
@@ -72,4 +72,21 @@ describe("updateBiomarkerSchema", () => {
});
expect(r.success).toBe(false);
});
it("accepts a hidden toggle", () => {
const r = updateBiomarkerSchema.safeParse({ hidden: true });
expect(r.success).toBe(true);
if (r.success) expect(r.data.hidden).toBe(true);
});
it("leaves hidden undefined when omitted", () => {
const r = updateBiomarkerSchema.safeParse({ unit: "mmol/L" });
expect(r.success).toBe(true);
if (r.success) expect(r.data.hidden).toBeUndefined();
});
it("rejects a non-boolean hidden", () => {
const r = updateBiomarkerSchema.safeParse({ hidden: "yes" });
expect(r.success).toBe(false);
});
});
+3
View File
@@ -72,6 +72,9 @@ export const updateBiomarkerSchema = z
upperBound: upperBound.or(z.null()),
context: optionalContext.or(z.null()),
panel: optionalPanel.or(z.null()),
// v1.22 — hide / unhide a marker. An omitted key leaves visibility
// untouched; `true` drops the marker from the active list + pickers.
hidden: z.boolean().optional(),
})
.refine(
(d) =>