From 09dbb1131df592b929f2cbf4d3f1da138619d3fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 10 Jun 2026 11:17:48 +0200 Subject: [PATCH] fix(medications): guard intake edits against implausible timestamps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- .../intake/[eventId]/__tests__/route.test.ts | 100 ++++++++++++++++++ .../[id]/intake/[eventId]/route.ts | 30 ++++++ .../intake-edit-dialog-seed.test.tsx | 64 +++++++++++ .../medications/dose-history-ledger.tsx | 3 + .../medications/intake-edit-dialog.tsx | 87 ++++++++++----- .../medications/intake-history-editable.tsx | 1 + .../__tests__/medication-intake-event.test.ts | 76 +++++++++++++ src/lib/validations/medication.ts | 21 +++- 14 files changed, 369 insertions(+), 31 deletions(-) create mode 100644 src/lib/validations/__tests__/medication-intake-event.test.ts diff --git a/messages/de.json b/messages/de.json index 8ab6d3dbb..430d15ebc 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1323,8 +1323,9 @@ "edit": { "dialogTitle": "Einnahme bearbeiten", "takenAtLabel": "Eingenommen um", + "scheduledForHint": "Geplant: {dateTime}", + "farFromScheduledWarning": "Liegt weit vom geplanten Zeitpunkt entfernt – Datum prüfen.", "skippedLabel": "Übersprungen", - "noteLabel": "Notiz", "save": "Speichern", "cancel": "Abbrechen", "savedToast": "Einnahme aktualisiert", diff --git a/messages/en.json b/messages/en.json index 58d054157..aac9080a9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1323,8 +1323,9 @@ "edit": { "dialogTitle": "Edit intake", "takenAtLabel": "Taken at", + "scheduledForHint": "Scheduled: {dateTime}", + "farFromScheduledWarning": "This is far from the scheduled time — check the date.", "skippedLabel": "Skipped", - "noteLabel": "Note", "save": "Save", "cancel": "Cancel", "savedToast": "Intake updated", diff --git a/messages/es.json b/messages/es.json index a07cce72b..b37cd76c0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1323,8 +1323,9 @@ "edit": { "dialogTitle": "Editar toma", "takenAtLabel": "Tomada a las", + "scheduledForHint": "Programada: {dateTime}", + "farFromScheduledWarning": "Está muy lejos de la hora programada; comprueba la fecha.", "skippedLabel": "Omitida", - "noteLabel": "Nota", "save": "Guardar", "cancel": "Cancelar", "savedToast": "Toma actualizada", diff --git a/messages/fr.json b/messages/fr.json index 75f0bb202..3399e0725 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1323,8 +1323,9 @@ "edit": { "dialogTitle": "Modifier la prise", "takenAtLabel": "Prise à", + "scheduledForHint": "Prévue : {dateTime}", + "farFromScheduledWarning": "Très éloignée de l’heure prévue — vérifiez la date.", "skippedLabel": "Sautée", - "noteLabel": "Note", "save": "Enregistrer", "cancel": "Annuler", "savedToast": "Prise mise à jour", diff --git a/messages/it.json b/messages/it.json index 8d6623f95..b56733170 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1323,8 +1323,9 @@ "edit": { "dialogTitle": "Modifica assunzione", "takenAtLabel": "Assunta alle", + "scheduledForHint": "Programmata: {dateTime}", + "farFromScheduledWarning": "Molto distante dall’orario programmato — controlla la data.", "skippedLabel": "Saltata", - "noteLabel": "Nota", "save": "Salva", "cancel": "Annulla", "savedToast": "Assunzione aggiornata", diff --git a/messages/pl.json b/messages/pl.json index c3266cdd8..71c5b8d23 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1323,8 +1323,9 @@ "edit": { "dialogTitle": "Edytuj przyjęcie", "takenAtLabel": "Przyjęto o", + "scheduledForHint": "Zaplanowano: {dateTime}", + "farFromScheduledWarning": "Znacznie odbiega od zaplanowanego terminu — sprawdź datę.", "skippedLabel": "Pominięto", - "noteLabel": "Notatka", "save": "Zapisz", "cancel": "Anuluj", "savedToast": "Przyjęcie zaktualizowane", diff --git a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts index 8337a3b27..cde47a0cd 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts @@ -358,6 +358,106 @@ describe("PUT — v1.15.18 band re-attribution", () => { expect((created?.scheduledFor as Date).getTime()).toBe(at(19, 0).getTime()); }); + it("422s an edited takenAt before the medication's start date (P0-4)", async () => { + // Use fixed wall-clock so the schema's "not in the future / within 5 + // years" bounds hold regardless of when the suite runs. + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-06-10T12:00:00Z")); + try { + vi.mocked(prisma.medication.findFirst).mockResolvedValue({ + id: "m1", + startsOn: new Date("2026-06-01T00:00:00Z"), + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00Z"), + schedules: [], + } as never); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({ + id: "e1", + userId: "user-1", + medicationId: "m1", + scheduledFor: at(7, 0), + takenAt: at(7, 5), + skipped: false, + } as never); + + const res = await PUT( + putReq({ takenAt: "2026-05-15T10:00:00+02:00" }), + ROUTE_CTX, + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("start date"); + // The guard fires before any write. + expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("does not false-reject a takenAt in the early hours of the start day (tz day-key)", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-06-10T12:00:00Z")); + try { + // startsOn 2026-06-01; the edit is 00:30 local Berlin on the start day, + // which is 2026-05-31T22:30Z — a naive UTC compare would reject it. + vi.mocked(prisma.medication.findFirst).mockResolvedValue({ + id: "m1", + startsOn: new Date("2026-06-01T00:00:00Z"), + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00Z"), + schedules: [ + { + id: "s1", + windowStart: "07:00", + windowEnd: "07:00", + daysOfWeek: null, + timesOfDay: ["07:00", "19:00"], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }, + ], + } as never); + vi.mocked(prisma.medicationIntakeEvent.findFirst) + .mockResolvedValueOnce({ + id: "e1", + userId: "user-1", + medicationId: "m1", + scheduledFor: at(7, 0), + takenAt: at(7, 5), + skipped: false, + } as never) + .mockResolvedValue(null as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue( + {} as never, + ); + vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({ + id: "e2", + userId: "user-1", + medicationId: "m1", + scheduledFor: new Date("2026-05-31T22:30:00Z"), + takenAt: new Date("2026-05-31T22:30:00Z"), + skipped: false, + } as never); + + const res = await PUT( + putReq({ takenAt: "2026-06-01T00:30:00+02:00" }), + ROUTE_CTX, + ); + expect(res.status).toBe(200); + } finally { + vi.useRealTimers(); + } + }); + it("422s a forceSlotInstant that is not a real slot", async () => { vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({ id: "e1", diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index 5a8a76f19..0e1916ada 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -14,6 +14,7 @@ import { updateIntakeEventSchema } from "@/lib/validations/medication"; import { reconcileOneShotState } from "@/lib/medications/lifecycle"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz"; import { applyCanonicalSlotWrite, resolveForcedSlotForWrite, @@ -76,6 +77,35 @@ export const PUT = apiHandler( const data = parsed.data; + // v1.15.19 — start-date guard on an edited `takenAt` (audit P0-4). The + // schema bounds the instant to a plausible absolute range; this check + // adds the per-medication floor: a take cannot predate the day the + // medication starts (calendar-day comparison in the user's timezone, so + // a take in the early hours of the start day never false-rejects). + if (data.takenAt instanceof Date) { + const medication = await prisma.medication.findFirst({ + where: { id, userId: user.id }, + select: { startsOn: true }, + }); + if (medication?.startsOn) { + const takenDay = dayKeyForUserTz(data.takenAt, user.timezone); + const startsOnDay = medication.startsOn.toISOString().slice(0, 10); + if (takenDay < startsOnDay) { + annotate({ + action: { + name: "medications.intake.event.update.before-start-date", + }, + meta: { medication_id: id, event_id: eventId }, + }); + return apiError( + "takenAt is before the medication's start date", + 422, + { errorCode: "medications.intake.taken_at.before_start" }, + ); + } + } + } + // The post-edit state of the row (current values where the body omits a // field) — drives the re-attribution decision. const nextTakenAt = diff --git a/src/components/medications/__tests__/intake-edit-dialog-seed.test.tsx b/src/components/medications/__tests__/intake-edit-dialog-seed.test.tsx index 3111fb0ed..51cebe654 100644 --- a/src/components/medications/__tests__/intake-edit-dialog-seed.test.tsx +++ b/src/components/medications/__tests__/intake-edit-dialog-seed.test.tsx @@ -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( + {}} + />, + ); + // `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( + {}} + />, + ); + 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( + {}} + />, + ); + expect(html).toContain( + "medications.detail.intake.edit.farFromScheduledWarning", + ); + }); + + it("no longer renders the note field the API never persisted (LOW-10)", () => { + const html = renderToStaticMarkup( + {}} + />, + ); + expect(html).not.toContain("intake-edit-note"); + expect(html).not.toContain("medications.detail.intake.edit.noteLabel"); + }); +}); diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx index d13ddd53e..138ebe900 100644 --- a/src/components/medications/dose-history-ledger.tsx +++ b/src/components/medications/dose-history-ledger.tsx @@ -136,6 +136,7 @@ export function DoseHistoryLedger({ id: string; takenAt: string | null; skipped: boolean; + scheduledFor: string | null; } | null>(null); const [pendingDeleteId, setPendingDeleteId] = useState(null); const [marking, setMarking] = useState(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" diff --git a/src/components/medications/intake-edit-dialog.tsx b/src/components/medications/intake-edit-dialog.tsx index 45e513221..ee0e389c9 100644 --- a/src/components/medications/intake-edit-dialog.tsx +++ b/src/components/medications/intake-edit-dialog.tsx @@ -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 `` + * Convert an ISO timestamp (or Date) into the `` * 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 && ( +

+ {t("medications.detail.intake.edit.scheduledForHint", { + dateTime: fmt.dateTime(scheduledFor), + })} +

+ )} + {/* Non-blocking: a 48h+ gap to the slot is almost always a + date typo, but a deliberate far edit stays saveable. */} + {showFarHint && ( +

+ {t("medications.detail.intake.edit.farFromScheduledWarning")} +

+ )} -
- -