fix(medications): make the nightly dedup and the pin path respect each other

A deliberately detached ad-hoc intake could be folded back into its slot
cluster by the nightly dedup, and a pinned row could lose its slot to a
sibling on the sync-version tiebreak — pins now outrank every other row
and detached rows never join a cluster, mirrored into the operator
repair script. Pinning onto an already-served slot no longer overwrites
the existing record silently; the API refuses with a clear error while
an identical re-post stays idempotent. The idempotency cache also
refuses to store invite-token-shaped responses.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 16:54:15 +02:00
1 parent 24928dbb38
commit 4ca3de5ff3
13 files changed
+575 -27

No files matched your search

@@ -458,6 +458,48 @@ describe("PUT — v1.15.18 band re-attribution", () => {
}
});
it("422s a pin onto a slot already served by another recorded action (v1.16.0)", async () => {
// The edited event is an ad-hoc take; the pin targets the real 07:00
// slot — but that slot already carries a DIFFERENT recorded take.
// Converging would overwrite it (last-write-wins), so the route
// refuses with `medications.intake.force_slot.occupied` before any
// write happens.
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
id: "e1",
userId: "user-1",
medicationId: "m1",
scheduledFor: at(12, 0),
takenAt: at(12, 0),
skipped: false,
} as never);
// findPinConflict's slot-row read: the 07:00 anchor is served.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{
id: "other-take",
takenAt: at(7, 5),
skipped: false,
idempotencyKey: null,
scheduledFor: at(7, 0),
source: "WEB",
createdAt: at(0, 1),
},
] as never);
const res = await PUT(
putReq({ forceSlotInstant: at(7, 0).toISOString() }),
ROUTE_CTX,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe(
"medications.intake.force_slot.occupied",
);
expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
});
it("422s a forceSlotInstant that is not a real slot", async () => {
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
id: "e1",
@@ -17,6 +17,7 @@ import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-
import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz";
import {
applyCanonicalSlotWrite,
findPinConflict,
resolveForcedSlotForWrite,
resolveSlotForWriteByBand,
} from "@/lib/medications/scheduling/slot-upsert";
@@ -158,6 +159,30 @@ export const PUT = apiHandler(
{ errorCode: "medications.intake.force_slot.invalid" },
);
}
// v1.16.0 — refuse to pin onto a slot another recorded action
// already serves (excluding the row being edited): the explicit-
// write last-write-wins rule would silently overwrite that dose
// record. The ledger UI only offers the pin for unserved slots,
// so this only fires for stale clients / raw API calls.
if (
await findPinConflict({
userId: user.id,
medicationId: id,
canonicalSlot: forced,
incomingTakenAt: nextSkipped ? null : nextTakenAt,
excludeEventId: eventId,
})
) {
annotate({
action: { name: "medication.intake.force_slot.occupied" },
meta: { medication_id: id, event_id: eventId },
});
return apiError(
"forceSlotInstant already carries a recorded dose action",
422,
{ errorCode: "medications.intake.force_slot.occupied" },
);
}
resolvedScheduledFor = forced;
attributionSource = "USER_PIN";
} else if (
@@ -24,6 +24,7 @@ import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
import {
applyCanonicalSlotWrite,
findPinConflict,
resolveForcedSlotForWrite,
resolveSlotForWriteByBand,
resolveSlotInstantForWrite,
@@ -198,6 +199,28 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
{ errorCode: "medications.intake.force_slot.invalid" },
);
}
// v1.16.0 — refuse to pin onto a slot another recorded action already
// serves: the explicit-write last-write-wins rule would silently
// overwrite that dose record. The ledger UI only offers the pin for
// unserved slots, so this only fires for stale clients / raw API calls.
if (
await findPinConflict({
userId: user.id,
medicationId: id,
canonicalSlot,
incomingTakenAt: resolvedTakenAt ?? null,
})
) {
annotate({
action: { name: "medication.intake.force_slot.occupied" },
meta: { medication_id: id },
});
return apiError(
"forceSlotInstant already carries a recorded dose action",
422,
{ errorCode: "medications.intake.force_slot.occupied" },
);
}
attributionSource = "USER_PIN";
} else {
// A non-skip write on this route always carries a `takenAt` (defaulted to
@@ -653,18 +653,22 @@ describe("POST /api/medications/intake/bulk — v1.15.20 band attribution", () =
it("forceSlotInstant pins an off-window take onto the named real slot", async () => {
// 14:00 Berlin falls in no band; the client pins the take onto the
// morning slot anchor (05:00Z = 07:00 Berlin).
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
{
id: "row-morning",
takenAt: null,
skipped: false,
idempotencyKey: null,
scheduledFor: new Date("2026-06-15T05:00:00Z"),
source: "REMINDER",
createdAt: new Date("2026-06-15T00:00:00Z"),
},
] as never);
// morning slot anchor (05:00Z = 07:00 Berlin). The slot rows are read
// twice on the pin path: once by the v1.16.0 occupied-slot guard
// (`findPinConflict` — a pending row is not a conflict), once by the
// canonical upsert itself.
const morningPendingRow = {
id: "row-morning",
takenAt: null,
skipped: false,
idempotencyKey: null,
scheduledFor: new Date("2026-06-15T05:00:00Z"),
source: "REMINDER",
createdAt: new Date("2026-06-15T00:00:00Z"),
};
vi.mocked(prisma.medicationIntakeEvent.findMany)
.mockResolvedValueOnce([morningPendingRow] as never)
.mockResolvedValueOnce([morningPendingRow] as never);
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
id: "row-morning",
} as never);
@@ -689,6 +693,47 @@ describe("POST /api/medications/intake/bulk — v1.15.20 band attribution", () =
expect(body.data.entries[0].id).toBe("row-morning");
});
it("marks an entry skipped when the pinned slot already carries a recorded action", async () => {
// v1.16.0 — the morning slot is already served by a different take;
// pinning another take onto it would overwrite that dose record via
// last-write-wins, so the entry is refused per-entry
// (`force_slot_occupied`) instead of converging.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
{
id: "row-morning",
takenAt: new Date("2026-06-15T05:10:00Z"),
skipped: false,
idempotencyKey: null,
scheduledFor: new Date("2026-06-15T05:00:00Z"),
source: "WEB",
createdAt: new Date("2026-06-15T00:00:00Z"),
},
] as never);
const res = await POST(
postReq({
entries: [
{
medicationId: "med-1",
takenAt: "2026-06-15T12:00:00.000Z",
forceSlotInstant: "2026-06-15T05:00:00.000Z",
},
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
skipped: Array<{ index: number; reason: string }>;
entries: Array<{ status: string; reason?: string }>;
};
};
expect(body.data.entries[0].status).toBe("skipped");
expect(body.data.entries[0].reason).toBe("force_slot_occupied");
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled();
});
it("marks an entry skipped when forceSlotInstant names no real slot", async () => {
const res = await POST(
postReq({
@@ -52,6 +52,7 @@ import {
} from "@/lib/rollups/medication-compliance-rollups";
import {
applyCanonicalSlotWrite,
findPinConflict,
resolveForcedSlotForWrite,
resolveSlotForWriteByBand,
resolveSlotInstantForWrite,
@@ -280,6 +281,23 @@ async function postBulk(request: NextRequest): Promise<Response> {
results.push({ index: i, status: "skipped", reason });
continue;
}
// v1.16.0 — refuse to pin onto a slot another recorded action
// already serves: the explicit-write last-write-wins rule would
// silently overwrite that dose record. Per-entry skip, mirroring
// the single route's 422 `medications.intake.force_slot.occupied`.
if (
await findPinConflict({
userId: user.id,
medicationId: entry.medicationId,
canonicalSlot,
incomingTakenAt: entry.takenAt ?? null,
})
) {
const reason = "force_slot_occupied";
skipped.push({ index: i, reason });
results.push({ index: i, status: "skipped", reason });
continue;
}
attributionSource = "USER_PIN";
} else if (isExplicitTaken) {
const attribution = await resolveSlotForWriteByBand({