fix(labs): preserve a reading's note when its decrypted load fails

The edit sheet loaded the decrypted note from the single-resource GET and
sent note:null on a load miss, silently wiping a note that simply could
not be read. The submit path now omits the note key entirely in that case
(server preserves the stored ciphertext), disables the textarea, and shows
a hint. Decision encoded in a pure resolveNoteForUpdate helper with tests.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 23:11:35 +02:00
1 parent 796f6bacd6
commit d1fde96e66
3 files changed
+124 -8

No files matched your search

+34 -8
View File
@@ -15,6 +15,7 @@ import { Textarea } from "@/components/ui/textarea";
import { ApiError, apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { formatDateShort } from "@/lib/format";
import { formatLabValue } from "@/lib/labs/format-value";
import { resolveNoteForUpdate } from "@/lib/labs/note-update";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -39,9 +40,10 @@ function parseDecimal(raw: string): number | null {
/**
* v1.18.1 — reverse-chronological reading history for one biomarker, with
* full edit / amend / delete (L5). Edit opens a sheet that loads the
* decrypted note from the single-resource GET; delete soft-deletes with an
* Undo toast that restores via `/api/labs/restore` (the measurement pattern).
* full edit + delete (L5). Edit opens a sheet that loads the decrypted note
* from the single-resource GET; delete soft-deletes with an Undo toast that
* restores via `/api/labs/restore` (the measurement pattern). An edit
* overwrites in place — there is no append-only amendment trail.
*/
export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
const { t } = useTranslations();
@@ -53,6 +55,12 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
const [editNote, setEditNote] = useState("");
const [editLoading, setEditLoading] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
// True when the decrypted-note GET failed to load for the row being edited.
// We must NOT send `note: null` in that case — an empty editor then would
// wipe a note we simply couldn't read. Instead the PUT omits `note` so the
// server preserves the stored ciphertext untouched.
const [noteLoadFailed, setNoteLoadFailed] = useState(false);
const [hasNote, setHasNote] = useState(false);
function invalidate() {
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
@@ -87,12 +95,14 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
id: string;
value: number;
takenAt: string;
note: string | null;
// `undefined` → omit `note` from the PUT (leave the stored note
// untouched); `null` → clear it; a string → set it.
note: string | null | undefined;
}) =>
apiPut<LabResultDto>(`/api/labs/${body.id}`, {
value: body.value,
takenAt: body.takenAt,
note: body.note,
...(body.note === undefined ? {} : { note: body.note }),
}),
onSuccess: () => {
invalidate();
@@ -112,6 +122,8 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
setEditTakenAt(toDateTimeLocal(reading.takenAt));
setEditNote("");
setEditError(null);
setNoteLoadFailed(false);
setHasNote(reading.hasNote);
// Load the decrypted note (list rows withhold it).
setEditLoading(true);
try {
@@ -120,7 +132,10 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
);
setEditNote(detail.note ?? "");
} catch {
/* leave note blank on a load miss */
// The note couldn't be loaded. Flag it so the submit path omits `note`
// (preserving the stored ciphertext) rather than sending `null` and
// wiping a note we just failed to read.
setNoteLoadFailed(true);
} finally {
setEditLoading(false);
}
@@ -149,7 +164,13 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
id: editingId,
value: numericValue,
takenAt: takenDate.toISOString(),
note: editNote.trim() ? editNote.trim() : null,
// `undefined` → omit `note` (server preserves the stored note when its
// decrypted load failed); `null` → clear; text → set.
note: resolveNoteForUpdate({
noteLoadFailed,
hadNote: hasNote,
editorValue: editNote,
}),
});
}
@@ -240,8 +261,13 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
onChange={(e) => setEditNote(e.target.value)}
maxLength={NOTE_MAX_LENGTH}
rows={2}
disabled={editLoading}
disabled={editLoading || (noteLoadFailed && hasNote)}
/>
{noteLoadFailed && hasNote ? (
<p className="text-muted-foreground text-xs">
{t("labs.noteLoadFailed")}
</p>
) : null}
</div>
{editError ? (
<p className="text-destructive text-sm" role="alert">
@@ -0,0 +1,66 @@
/**
* v1.18.1 — the lab-edit note-resolution guard.
*
* Core invariant: a failed decrypted-note load must NEVER send `note: null`
* for a row that had a note — that would silently wipe it. The submit path
* omits `note` (→ `undefined`) in that case so the server preserves it.
*/
import { describe, expect, it } from "vitest";
import { resolveNoteForUpdate } from "@/lib/labs/note-update";
describe("resolveNoteForUpdate", () => {
it("omits note (undefined) when the load failed and the row had a note", () => {
expect(
resolveNoteForUpdate({
noteLoadFailed: true,
hadNote: true,
editorValue: "",
}),
).toBeUndefined();
});
it("clears (null) when the load failed but the row had no note", () => {
// No note to preserve → an empty editor legitimately clears.
expect(
resolveNoteForUpdate({
noteLoadFailed: true,
hadNote: false,
editorValue: "",
}),
).toBeNull();
});
it("clears (null) when the editor is emptied on a successful load", () => {
expect(
resolveNoteForUpdate({
noteLoadFailed: false,
hadNote: true,
editorValue: " ",
}),
).toBeNull();
});
it("sets the trimmed text when the editor has content", () => {
expect(
resolveNoteForUpdate({
noteLoadFailed: false,
hadNote: true,
editorValue: " fasting, morning ",
}),
).toBe("fasting, morning");
});
it("always preserves (undefined) when load failed on a row with a note, even with editor content", () => {
// The edit sheet disables the note textarea in this state, so the editor
// value is the stale-blank initial value. The guard must omit `note`
// unconditionally here so a failed-to-decrypt note is never overwritten.
expect(
resolveNoteForUpdate({
noteLoadFailed: true,
hadNote: true,
editorValue: "stale",
}),
).toBeUndefined();
});
});
+24
View File
@@ -0,0 +1,24 @@
/**
* v1.18.1 — note-resolution for the lab-reading edit PUT.
*
* The edit sheet loads a row's decrypted note from the single-resource GET.
* If that load FAILS we must never send `note: null` — an empty editor would
* then wipe a note we simply couldn't read. This pure helper encodes the
* three-way decision the submit path uses:
*
* - load failed AND the row had a note → `undefined` (omit `note` from the
* PUT body so the server preserves the stored ciphertext untouched).
* - editor is empty → `null` (clear the note).
* - editor has text → the trimmed text (set the note).
*
* `undefined` is the signal the caller uses to OMIT the key entirely.
*/
export function resolveNoteForUpdate(args: {
noteLoadFailed: boolean;
hadNote: boolean;
editorValue: string;
}): string | null | undefined {
if (args.noteLoadFailed && args.hadNote) return undefined;
const trimmed = args.editorValue.trim();
return trimmed ? trimmed : null;
}