mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user