mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
796f6bacd6
commit
d1fde96e66
3 files changed
+124
-8
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user