mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
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:
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({
|
||||
|
||||
Reference in new issue
Block a user