From 9532033277ae2a60ec3349ab92feb6678573247b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 8 Jun 2026 18:07:56 +0200 Subject: [PATCH] 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. --- .../intake/[eventId]/__tests__/route.test.ts | 131 ++++++++++++++++++ .../[id]/intake/[eventId]/route.ts | 131 +++++++++++++++--- src/app/api/medications/intake/route.ts | 119 +++++++++++++--- 3 files changed, 341 insertions(+), 40 deletions(-) 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 971cebb19..8337a3b27 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 @@ -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); + }); +}); diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index 7468eaeee..5a8a76f19 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -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, }); } diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 0bd2d72fc..b9f35fd7e 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -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 },