mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
0761ce2346
commit
09dbb1131d
14 files changed
+369
-31
No files matched your search
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* v1.15.19 — `updateIntakeEventSchema.takenAt` plausibility bounds
|
||||
* (audit P0-4).
|
||||
*
|
||||
* A date typo on an intake edit could previously park `takenAt` on any
|
||||
* instant — a month before the slot, a year in the future — with no
|
||||
* pushback. The schema now rejects a future `takenAt` (beyond a small
|
||||
* clock-skew allowance) and anything older than 5 years; the
|
||||
* per-medication start-date floor lives in the route, the slot-distance
|
||||
* hint in the edit dialog.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { updateIntakeEventSchema } from "@/lib/validations/medication";
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const YEAR_MS = 365 * 24 * HOUR_MS;
|
||||
|
||||
describe("updateIntakeEventSchema — takenAt bounds (P0-4)", () => {
|
||||
it("accepts a takenAt in the recent past", () => {
|
||||
const result = updateIntakeEventSchema.safeParse({
|
||||
takenAt: new Date(Date.now() - 2 * HOUR_MS).toISOString(),
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.takenAt).toBeInstanceOf(Date);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a takenAt slightly ahead of now (clock skew)", () => {
|
||||
const result = updateIntakeEventSchema.safeParse({
|
||||
takenAt: new Date(Date.now() + 2 * 60 * 1000).toISOString(),
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a takenAt in the future beyond the skew allowance", () => {
|
||||
const result = updateIntakeEventSchema.safeParse({
|
||||
takenAt: new Date(Date.now() + HOUR_MS).toISOString(),
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0]?.message).toContain("future");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a takenAt more than 5 years in the past", () => {
|
||||
const result = updateIntakeEventSchema.safeParse({
|
||||
takenAt: new Date(Date.now() - 6 * YEAR_MS).toISOString(),
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0]?.message).toContain("5 years");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a takenAt within the 5-year window", () => {
|
||||
const result = updateIntakeEventSchema.safeParse({
|
||||
takenAt: new Date(Date.now() - 4 * YEAR_MS).toISOString(),
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("still accepts an explicit null takenAt (clears the take)", () => {
|
||||
const result = updateIntakeEventSchema.safeParse({
|
||||
takenAt: null,
|
||||
skipped: true,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("still accepts a body that omits takenAt entirely", () => {
|
||||
const result = updateIntakeEventSchema.safeParse({ skipped: false });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -693,11 +693,30 @@ export const listIntakeEventsSchema = z.object({
|
||||
.default("all"),
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.15.19 — `takenAt` plausibility bounds on the edit path (audit P0-4).
|
||||
* A date typo on an intake edit could park `takenAt` a month before its
|
||||
* slot with no pushback anywhere. The schema rejects the physically
|
||||
* implausible cases: a future instant (small skew allowance for client
|
||||
* clocks) and anything older than the 5-year window the GLP-1 dose-change
|
||||
* validator already established (`glp1DoseChangePostSchema`). Slot-distance
|
||||
* checks stay out of the schema — it cannot see the medication — and live
|
||||
* in the route (start-date guard) + the edit dialog (non-blocking hint).
|
||||
*/
|
||||
const TAKEN_AT_CLOCK_SKEW_MS = 5 * 60 * 1000;
|
||||
const TAKEN_AT_MAX_AGE_MS = 5 * 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export const updateIntakeEventSchema = z
|
||||
.object({
|
||||
takenAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.refine((d) => d.getTime() <= Date.now() + TAKEN_AT_CLOCK_SKEW_MS, {
|
||||
message: "takenAt must not be in the future",
|
||||
})
|
||||
.refine((d) => d.getTime() >= Date.now() - TAKEN_AT_MAX_AGE_MS, {
|
||||
message: "takenAt must be within the last 5 years",
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
skipped: z.boolean().optional(),
|
||||
@@ -723,7 +742,7 @@ export const updateIntakeEventSchema = z
|
||||
.meta({
|
||||
id: "UpdateMedicationIntakeEventRequest",
|
||||
description:
|
||||
"Edit a single intake event. v1.15.18 re-runs window-band slot attribution whenever `takenAt` or `skipped` change, snapping `scheduledFor` to the matched slot (or the take's own time when it falls in no window). `forceSlotInstant` overrides that to pin the take onto a named real slot; an explicit `scheduledFor` still wins when supplied directly.",
|
||||
"Edit a single intake event. v1.15.18 re-runs window-band slot attribution whenever `takenAt` or `skipped` change, snapping `scheduledFor` to the matched slot (or the take's own time when it falls in no window). `forceSlotInstant` overrides that to pin the take onto a named real slot; an explicit `scheduledFor` still wins when supplied directly. `takenAt` must not be in the future (5-minute clock-skew allowance) nor more than 5 years in the past; a `takenAt` before the medication's start date returns 422.",
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user