fix(medications): re-attribute a dose's slot when an intake edit moves it

Editing a takenAt or a status toggle now re-runs window-band attribution and
sets scheduledFor to the matched slot (the take's own time on a miss), instead
of leaving the slot stale. A slot move tombstones the source row and routes the
corrected dose through the shared canonical-slot upsert so the write is
P2002-safe rather than bare-updating into an occupied slot. Both edit surfaces
(the per-event PUT and the status-toggle POST) accept forceSlotInstant to pin
an off-window edit onto a named real slot, rejecting a non-slot with 422.
This commit is contained in:
Marc-André Bombeck committed 2026-06-08 18:07:56 +02:00
1 parent c2453d6b8c
commit 9532033277
3 files changed
+341 -40

No files matched your search

@@ -12,10 +12,15 @@ vi.mock("@/lib/db", () => ({
update: vi.fn(),
delete: vi.fn(),
findFirst: vi.fn(),
// v1.15.18 — the band resolver + the canonical slot upsert read these.
findMany: vi.fn(),
create: vi.fn(),
},
medication: {
findUnique: vi.fn(),
findFirst: vi.fn(),
updateMany: vi.fn(),
update: vi.fn(),
},
auditLog: { create: vi.fn() },
},
@@ -250,3 +255,129 @@ describe("PUT /api/medications/[id]/intake/[eventId] — one-shot reconcile on s
});
});
});
// ────────────────────────────────────────────────────────────────────
// v1.15.18 — PUT re-runs window-band slot attribution on a takenAt change
// ────────────────────────────────────────────────────────────────────
import { localHmAsUtc } from "@/lib/timezone";
const TZ = "Europe/Berlin";
const SESSION_TZ = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const, timezone: TZ },
};
const ATTR_DAY = new Date("2026-06-05T12:00:00Z");
function at(h: number, m: number): Date {
return localHmAsUtc(ATTR_DAY, TZ, h, m);
}
/** Stub the band resolver's medication load (07:00 / 19:00 twice-daily). */
function stubTwiceDailyMed() {
vi.mocked(prisma.medication.findFirst).mockResolvedValue({
id: "m1",
startsOn: null,
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);
}
describe("PUT — v1.15.18 band re-attribution", () => {
beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_TZ as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
oneShot: false,
active: true,
} as never);
vi.mocked(prisma.medication.update).mockResolvedValue({} as never);
stubTwiceDailyMed();
});
it("re-snaps the dose onto the matched slot when the edited takenAt moves it (slot move)", async () => {
// The row currently sits on the 07:00 slot; the edit moves takenAt to
// 19:05 (on-time for the 19:00 slot). The route tombstones the row + routes
// the dose through the canonical slot upsert onto the 19:00 anchor.
vi.mocked(prisma.medicationIntakeEvent.findFirst)
// PUT lookup of the edited event
.mockResolvedValueOnce({
id: "e1",
userId: "user-1",
medicationId: "m1",
scheduledFor: at(7, 0),
takenAt: at(7, 5),
skipped: false,
} as never)
// lifecycle liveIntake probe
.mockResolvedValue(null as never);
// applyCanonicalSlotWrite: no existing row at the 19:00 slot → create.
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: at(19, 0),
takenAt: at(19, 5),
skipped: false,
} as never);
const res = await PUT(
putReq({ takenAt: at(19, 5).toISOString() }),
ROUTE_CTX,
);
expect(res.status).toBe(200);
// The original row was tombstoned (deletedAt set).
const tombstone = vi
.mocked(prisma.medicationIntakeEvent.update)
.mock.calls.find((c) => "deletedAt" in (c[0]?.data ?? {}));
expect(tombstone).toBeTruthy();
// The corrected dose was written onto the 19:00 anchor.
const created = vi.mocked(prisma.medicationIntakeEvent.create).mock
.calls[0]?.[0]?.data;
expect((created?.scheduledFor as Date).getTime()).toBe(at(19, 0).getTime());
});
it("422s a forceSlotInstant that is not a real slot", async () => {
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);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
const res = await PUT(
putReq({
takenAt: at(11, 29).toISOString(),
forceSlotInstant: at(11, 29).toISOString(), // not a slot
}),
ROUTE_CTX,
);
expect(res.status).toBe(422);
});
});
@@ -14,6 +14,11 @@ 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 {
applyCanonicalSlotWrite,
resolveForcedSlotForWrite,
resolveSlotForWriteByBand,
} from "@/lib/medications/scheduling/slot-upsert";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string; eventId: string }> };
@@ -70,20 +75,105 @@ export const PUT = apiHandler(
}
const data = parsed.data;
const updated = await prisma.medicationIntakeEvent.update({
where: { id: eventId },
data: {
...(data.takenAt !== undefined && { takenAt: data.takenAt }),
...(data.skipped !== undefined && { skipped: data.skipped }),
...(data.scheduledFor !== undefined && {
scheduledFor: data.scheduledFor,
}),
// v1.7.0 sync — bump the reconciliation counter on every
// server-side mutation so the `/api/sync/changes` feed echoes a
// monotonic value to paired clients.
syncVersion: { increment: 1 },
},
});
// The post-edit state of the row (current values where the body omits a
// field) — drives the re-attribution decision.
const nextTakenAt =
data.takenAt !== undefined ? data.takenAt : event.takenAt;
const nextSkipped =
data.skipped !== undefined ? data.skipped : event.skipped;
const takenOrSkippedChanged =
(data.takenAt !== undefined &&
data.takenAt?.getTime() !== event.takenAt?.getTime()) ||
(data.skipped !== undefined && data.skipped !== event.skipped);
// v1.15.18 — re-run window-band attribution when the edit moves `takenAt`
// or `skipped`, so an edited time re-binds to the correct slot instead of
// leaving `scheduledFor` stale (audit HIGH-4). Precedence:
// 1. an explicit `scheduledFor` in the body wins (deliberate override);
// 2. else `forceSlotInstant` pins onto a named real slot (422 if it is
// not a slot of this med);
// 3. else a taken edit attributes by band (slot anchor on match, the
// take's own time on a miss → ad-hoc);
// 4. a skip / pending edit keeps the existing `scheduledFor` anchor.
let resolvedScheduledFor: Date | undefined = data.scheduledFor;
if (resolvedScheduledFor === undefined && takenOrSkippedChanged) {
if (data.forceSlotInstant !== undefined) {
const forced = await resolveForcedSlotForWrite({
userId: user.id,
medicationId: id,
userTz: user.timezone,
slotInstant: data.forceSlotInstant,
});
if (forced === null) {
annotate({
action: { name: "medication.intake.force_slot.invalid" },
meta: { medication_id: id, event_id: eventId },
});
return apiError(
"forceSlotInstant is not a scheduled slot of this medication",
422,
{ errorCode: "medications.intake.force_slot.invalid" },
);
}
resolvedScheduledFor = forced;
} else if (nextTakenAt !== null && !nextSkipped) {
const attribution = await resolveSlotForWriteByBand({
userId: user.id,
medicationId: id,
userTz: user.timezone,
takenAt: nextTakenAt,
});
// band.at on match; the take's own time on a miss (ad-hoc).
resolvedScheduledFor = attribution.slotInstant ?? nextTakenAt;
}
// skip / pending edits leave `scheduledFor` on the existing anchor.
}
const targetScheduledFor = resolvedScheduledFor ?? event.scheduledFor;
const slotMoved =
targetScheduledFor.getTime() !== event.scheduledFor.getTime();
let updated;
if (!slotMoved) {
// The slot is unchanged — a bare in-place update can never collide with
// a sibling row (it is its own slot).
updated = await prisma.medicationIntakeEvent.update({
where: { id: eventId },
data: {
...(data.takenAt !== undefined && { takenAt: data.takenAt }),
...(data.skipped !== undefined && { skipped: data.skipped }),
// v1.7.0 sync — bump the reconciliation counter on every
// server-side mutation so the `/api/sync/changes` feed echoes a
// monotonic value to paired clients.
syncVersion: { increment: 1 },
},
});
} else {
// The edit moved the dose to a different slot. Tombstone the original
// row (the iOS "a correction is a tombstone + re-insert" model the
// DELETE handler documents) and route the corrected dose through the
// shared canonical-slot upsert, which converges onto any row already at
// the target slot (a pending REMINDER row) rather than bare-updating
// into an occupied slot and risking a P2002 (audit HIGH-4).
await prisma.medicationIntakeEvent.update({
where: { id: eventId },
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
const applied = await applyCanonicalSlotWrite({
client: prisma,
userId: user.id,
medicationId: id,
canonicalSlot: targetScheduledFor,
takenAt: nextSkipped ? null : nextTakenAt,
skipped: nextSkipped,
isExplicitTaken: !nextSkipped && nextTakenAt !== null,
isExplicitSkip: nextSkipped,
idempotencyKey: null,
createSource: "WEB",
});
updated = applied.row;
}
await auditLog("medication.intake.update", {
userId: user.id,
@@ -105,23 +195,20 @@ export const PUT = apiHandler(
invalidateUserMedications(user.id);
// v1.4.39 W-MED — refresh the rollup row for both the old + new
// scheduledFor day-keys when the timestamp moved across a day
// boundary; if only takenAt / skipped changed the second call
// collapses to a no-op upsert.
// scheduledFor day-keys when the dose moved across a day boundary
// (band re-attribution can move it as well as an explicit edit); if the
// slot is unchanged the second call collapses to a no-op upsert.
await recomputeMedicationComplianceForEvent({
userId: user.id,
medicationId: id,
scheduledFor: event.scheduledFor,
tz: user.timezone,
});
if (
data.scheduledFor !== undefined &&
data.scheduledFor.getTime() !== event.scheduledFor.getTime()
) {
if (slotMoved) {
await recomputeMedicationComplianceForEvent({
userId: user.id,
medicationId: id,
scheduledFor: data.scheduledFor,
scheduledFor: targetScheduledFor,
tz: user.timezone,
});
}
+101 -18
View File
@@ -36,6 +36,11 @@ import {
} from "@/lib/analytics/compliance";
import { getUserTodayBounds } from "@/lib/timezone";
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
import {
applyCanonicalSlotWrite,
resolveForcedSlotForWrite,
resolveSlotForWriteByBand,
} from "@/lib/medications/scheduling/slot-upsert";
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
import {
injectionSiteEnum,
@@ -62,6 +67,14 @@ const updateSchema = z.object({
// status toggle. Validated server-side against the medication's
// effective allowed set; ignored for skipped / snoozed.
injectionSite: injectionSiteEnum.optional(),
// v1.15.18 — late-take "attribute anyway" pin. When a "taken" toggle lands
// outside every dose window the UI can offer to pin the take onto a chosen
// real slot ("diesem Slot zuordnen?"); the server validates the instant is
// a real slot of the medication (422 otherwise). Ignored for skip / snooze.
forceSlotInstant: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
});
function startOfDayInTz(date: Date, tz: string): Date {
@@ -380,7 +393,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 422);
}
const { intakeId, status, takenAt, snoozedUntil, injectionSite } =
const { intakeId, status, takenAt, snoozedUntil, injectionSite, forceSlotInstant } =
parsed.data;
// v1.7.0 sync — a tombstoned intake 404s on a status toggle; the
@@ -436,26 +449,96 @@ export const POST = apiHandler(async (request: NextRequest) => {
let updated;
if (status === "taken") {
[updated] = await prisma.$transaction([
prisma.medicationIntakeEvent.update({
const resolvedTakenAt = takenAt ?? new Date();
// v1.15.18 — re-run window-band attribution on a taken toggle so an
// edited / off-window take re-binds to the right slot instead of leaving
// `scheduledFor` stale (audit HIGH-4). `forceSlotInstant` pins onto a
// named real slot (422 if it is not one); otherwise band membership picks
// the slot (the take's own time on a miss → ad-hoc). Mirrors the
// per-event PUT route.
let targetScheduledFor: Date;
if (forceSlotInstant !== undefined) {
const forced = await resolveForcedSlotForWrite({
userId: user.id,
medicationId: existing.medicationId,
userTz: userTzForHook,
slotInstant: forceSlotInstant,
});
if (forced === null) {
annotate({
action: { name: "medication.intake.force_slot.invalid" },
meta: { medication_id: existing.medicationId, intake_id: intakeId },
});
return apiError(
"forceSlotInstant is not a scheduled slot of this medication",
422,
{ errorCode: "medications.intake.force_slot.invalid" },
);
}
targetScheduledFor = forced;
} else {
const attribution = await resolveSlotForWriteByBand({
userId: user.id,
medicationId: existing.medicationId,
userTz: userTzForHook,
takenAt: resolvedTakenAt,
});
targetScheduledFor = attribution.slotInstant ?? resolvedTakenAt;
}
const slotMoved =
targetScheduledFor.getTime() !== existing.scheduledFor.getTime();
if (!slotMoved) {
[updated] = await prisma.$transaction([
prisma.medicationIntakeEvent.update({
where: { id: intakeId },
// v1.7.0 sync — bump the reconciliation counter on every
// server-side mutation so the delta feed echoes a monotonic value.
data: {
takenAt: resolvedTakenAt,
skipped: false,
syncVersion: { increment: 1 },
// v1.8.5 — persist the resolved site on the taken branch.
...(resolvedInjectionSite !== null && {
injectionSite: resolvedInjectionSite,
}),
},
}),
prisma.medication.update({
where: { id: existing.medicationId },
data: { snoozedUntil: null },
}),
]);
} else {
// The take re-attributed to a different slot. Tombstone the source row
// and route the dose through the shared canonical-slot upsert, which
// converges onto any row already at the target slot rather than
// bare-updating into an occupied slot (P2002-safe).
await prisma.medicationIntakeEvent.update({
where: { id: intakeId },
// v1.7.0 sync — bump the reconciliation counter on every
// server-side mutation so the delta feed echoes a monotonic value.
data: {
takenAt: takenAt ?? new Date(),
skipped: false,
syncVersion: { increment: 1 },
// v1.8.5 — persist the resolved site on the taken branch.
...(resolvedInjectionSite !== null && {
injectionSite: resolvedInjectionSite,
}),
},
}),
prisma.medication.update({
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
const applied = await applyCanonicalSlotWrite({
client: prisma,
userId: user.id,
medicationId: existing.medicationId,
canonicalSlot: targetScheduledFor,
takenAt: resolvedTakenAt,
skipped: false,
isExplicitTaken: true,
isExplicitSkip: false,
idempotencyKey: null,
createSource: "WEB",
injectionSite: resolvedInjectionSite,
});
updated = applied.row;
await prisma.medication.update({
where: { id: existing.medicationId },
data: { snoozedUntil: null },
}),
]);
});
}
} else if (status === "skipped") {
updated = await prisma.medicationIntakeEvent.update({
where: { id: intakeId },