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

@@ -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",
@@ -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 =