diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx index cdbeb0b0c..8254f1429 100644 --- a/src/components/labs/lab-history-list.tsx +++ b/src/components/labs/lab-history-list.tsx @@ -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(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(`/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 ? ( +

+ {t("labs.noteLoadFailed")} +

+ ) : null} {editError ? (

diff --git a/src/lib/labs/__tests__/note-update.test.ts b/src/lib/labs/__tests__/note-update.test.ts new file mode 100644 index 000000000..646e0247b --- /dev/null +++ b/src/lib/labs/__tests__/note-update.test.ts @@ -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(); + }); +}); diff --git a/src/lib/labs/note-update.ts b/src/lib/labs/note-update.ts new file mode 100644 index 000000000..c123eef5e --- /dev/null +++ b/src/lib/labs/note-update.ts @@ -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; +}