fix(medications): guard intake edits against implausible timestamps

An intake edit accepted any takenAt value, so a date typo could park a
dose a month away from its slot and poison the today feed, the rollup
and the rolling next-due anchor at once. The update schema now rejects
future timestamps (with clock-skew tolerance) and values older than five
years, the route rejects edits before the medication's start date, and
the edit dialog caps the picker at now, shows the scheduled slot and
warns when the new time sits more than 48 hours from it. The dialog's
note field is gone — it was never persisted and silently discarded
input.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 11:17:48 +02:00
1 parent 0761ce2346
commit 09dbb1131d
14 files changed
+369 -31

No files matched your search

@@ -17,6 +17,9 @@ import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({ t: (key: string) => key }),
useFormatters: () => ({
dateTime: (value: string | Date) => new Date(value).toISOString(),
}),
}));
vi.mock("@tanstack/react-query", () => ({
@@ -77,3 +80,64 @@ describe("IntakeEditDialog seed (G-1 §9 Q1)", () => {
expect(html).toBe("");
});
});
describe("IntakeEditDialog guardrails (P0-4)", () => {
it("caps the datetime-local picker at now via the max attribute", () => {
const taken = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const html = renderToStaticMarkup(
<IntakeEditDialog
medicationId="med-1"
event={{ id: "evt-1", takenAt: taken, skipped: false }}
onClose={() => {}}
/>,
);
// `YYYY-MM-DDTHH:mm` — exact value depends on render time, so pin the
// attribute presence + shape rather than the instant.
expect(html).toMatch(/max="\d{4}-\d{2}-\d{2}T\d{2}:\d{2}"/);
});
it("renders the scheduled-slot reference when the event carries one", () => {
const slot = new Date(2026, 2, 4, 8, 0).toISOString();
const taken = new Date(2026, 2, 4, 8, 10).toISOString();
const html = renderToStaticMarkup(
<IntakeEditDialog
medicationId="med-1"
event={{ id: "evt-1", takenAt: taken, skipped: false, scheduledFor: slot }}
onClose={() => {}}
/>,
);
expect(html).toContain("medications.detail.intake.edit.scheduledForHint");
// An on-time take never triggers the far-from-slot warning.
expect(html).not.toContain(
"medications.detail.intake.edit.farFromScheduledWarning",
);
});
it("shows the non-blocking far-from-slot hint when takenAt sits >48h from the slot", () => {
const slot = new Date(2026, 2, 4, 8, 0).toISOString();
// A month-off typo — exactly the P0-4 production case.
const taken = new Date(2026, 1, 4, 8, 0).toISOString();
const html = renderToStaticMarkup(
<IntakeEditDialog
medicationId="med-1"
event={{ id: "evt-1", takenAt: taken, skipped: false, scheduledFor: slot }}
onClose={() => {}}
/>,
);
expect(html).toContain(
"medications.detail.intake.edit.farFromScheduledWarning",
);
});
it("no longer renders the note field the API never persisted (LOW-10)", () => {
const html = renderToStaticMarkup(
<IntakeEditDialog
medicationId="med-1"
event={{ id: "evt-1", takenAt: null, skipped: false }}
onClose={() => {}}
/>,
);
expect(html).not.toContain("intake-edit-note");
expect(html).not.toContain("medications.detail.intake.edit.noteLabel");
});
});
@@ -136,6 +136,7 @@ export function DoseHistoryLedger({
id: string;
takenAt: string | null;
skipped: boolean;
scheduledFor: string | null;
} | null>(null);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const [marking, setMarking] = useState<string | null>(null);
@@ -384,6 +385,7 @@ function LedgerRowItem({
id: string;
takenAt: string | null;
skipped: boolean;
scheduledFor: string | null;
}) => void;
onDelete: (id: string) => void;
}) {
@@ -499,6 +501,7 @@ function LedgerRowItem({
id: intake.id as string,
takenAt: intake.takenAt,
skipped: intake.skipped,
scheduledFor: intake.scheduledFor,
})
}
className="min-h-9"
@@ -6,11 +6,19 @@
*
* Restores the row-level edit affordance the v1.5.4 flat-form
* retirement displaced (I-1 §15). The dialog opens from the
* intake-history kebab; fields are `takenAt` (datetime-local),
* `skipped` (boolean), `note` (string?). On save it PATCHes
* intake-history kebab; fields are `takenAt` (datetime-local) and
* `skipped` (boolean). On save it PATCHes
* `/api/medications/{id}/intake/{eventId}` and fires the
* medication cache bundle so the inline compliance tile + dashboard
* chart converge in the same tick.
*
* v1.15.19 (audit P0-4) — date-typo guardrails on the edit path:
* the picker is capped at "now" (mirrors the add dialog), the row's
* scheduled slot renders under the input so the user sees what they
* are editing against, and a non-blocking hint appears when the
* picked time sits more than 48h from that slot. The former free-text
* note field is gone — the API schema stripped it and nothing ever
* persisted it (audit LOW-10), so the dialog no longer pretends.
*/
import { useState } from "react";
@@ -29,8 +37,7 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
export interface IntakeEditDialogProps {
@@ -40,20 +47,28 @@ export interface IntakeEditDialogProps {
id: string;
takenAt: string | null;
skipped: boolean;
note?: string | null;
/** The row's slot anchor, ISO — renders as the "Geplant" reference
* and drives the far-from-slot hint. */
scheduledFor?: string | null;
} | null;
onClose: () => void;
}
/** Past this gap between the picked time and the scheduled slot the
* dialog surfaces the "check the date" hint — generous enough that a
* genuinely late dose never nags, tight enough that a month-off typo
* cannot pass silently. */
const FAR_FROM_SLOT_MS = 48 * 60 * 60 * 1000;
/**
* Convert an ISO timestamp into the `<input type="datetime-local">`
* Convert an ISO timestamp (or Date) into the `<input type="datetime-local">`
* shape `YYYY-MM-DDTHH:mm`. Returns an empty string for null inputs.
* The string is rendered in the user's local time so the picker
* matches the dose timestamp the user remembers.
*/
function toDateTimeLocal(iso: string | null): string {
if (!iso) return "";
const d = new Date(iso);
function toDateTimeLocal(value: string | Date | null): string {
if (!value) return "";
const d = value instanceof Date ? value : new Date(value);
if (Number.isNaN(d.getTime())) return "";
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(
@@ -61,6 +76,20 @@ function toDateTimeLocal(iso: string | null): string {
)}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** True when the picked local datetime sits more than 48h from the
* row's scheduled slot. Unparseable or absent values never warn. */
export function isFarFromSlot(
takenAtLocal: string,
scheduledFor: string | null | undefined,
): boolean {
if (!takenAtLocal || !scheduledFor) return false;
const taken = new Date(takenAtLocal);
const slot = new Date(scheduledFor);
if (Number.isNaN(taken.getTime()) || Number.isNaN(slot.getTime()))
return false;
return Math.abs(taken.getTime() - slot.getTime()) > FAR_FROM_SLOT_MS;
}
export function IntakeEditDialog(props: IntakeEditDialogProps) {
// Re-mount the body whenever the event id flips so the form state
// initialises from the row the user clicked. The wrapper is also
@@ -75,16 +104,19 @@ function IntakeEditDialogBody({
onClose,
}: IntakeEditDialogProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
const [takenAt, setTakenAt] = useState(() =>
toDateTimeLocal(event?.takenAt ?? null),
);
const [skipped, setSkipped] = useState(() => event?.skipped ?? false);
const [note, setNote] = useState(() => event?.note ?? "");
const [busy, setBusy] = useState(false);
if (!event) return null;
const scheduledFor = event.scheduledFor ?? null;
const showFarHint = !skipped && isFarFromSlot(takenAt, scheduledFor);
async function save() {
if (!event || busy) return;
setBusy(true);
@@ -100,9 +132,6 @@ function IntakeEditDialogBody({
} else {
body.takenAt = null;
}
if (note.trim().length > 0) {
body.note = note;
}
const res = await fetch(
`/api/medications/${medicationId}/intake/${event.id}`,
{
@@ -142,9 +171,30 @@ function IntakeEditDialogBody({
id="intake-edit-taken-at"
type="datetime-local"
value={takenAt}
max={toDateTimeLocal(new Date())}
onChange={(e) => setTakenAt(e.target.value)}
disabled={skipped}
/>
{scheduledFor && (
<p
className="text-muted-foreground text-xs"
data-slot="intake-edit-scheduled-hint"
>
{t("medications.detail.intake.edit.scheduledForHint", {
dateTime: fmt.dateTime(scheduledFor),
})}
</p>
)}
{/* Non-blocking: a 48h+ gap to the slot is almost always a
date typo, but a deliberate far edit stays saveable. */}
{showFarHint && (
<p
className="text-xs text-amber-600 dark:text-amber-400"
data-slot="intake-edit-far-hint"
>
{t("medications.detail.intake.edit.farFromScheduledWarning")}
</p>
)}
</div>
<label
htmlFor="intake-edit-skipped"
@@ -159,17 +209,6 @@ function IntakeEditDialogBody({
onCheckedChange={(checked) => setSkipped(checked)}
/>
</label>
<div className="space-y-1">
<Label htmlFor="intake-edit-note">
{t("medications.detail.intake.edit.noteLabel")}
</Label>
<Textarea
id="intake-edit-note"
value={note}
onChange={(e) => setNote(e.target.value)}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={busy}>
@@ -207,6 +207,7 @@ export function IntakeHistoryEditable({
id: editingEvent.id,
takenAt: editingEvent.takenAt,
skipped: editingEvent.skipped,
scheduledFor: editingEvent.scheduledFor,
}
: null
}