fix(medications): converge intake writes onto one row per scheduled slot

A twice-daily medication ended up with two rows for one dose slot: the
projector / reminder worker mints a pending row (source REMINDER) at the
slot's `localHmAsUtc` instant, while a "taken" write from the per-med or
bulk intake route inserted a fresh row (source WEB / API). Because
`source` is part of the `(userId, medicationId, scheduledFor, source)`
unique key — and the written instant could drift by a minute from the
projected one — the two rows coexisted: one taken, one still pending and
overdue. The card then read the slot as taken, counted 100%, and
suppressed the "due now" prompt for a dose the user had not taken.

Resolve every scheduled intake write to the slot's canonical instant
before writing and upsert in place, source-agnostic:

- `resolve-slot-instant.ts` snaps an incoming instant to the nearest
  scheduled slot for that day (enumerated through the canonical
  recurrence engine, so PRN and cyclic off-weeks resolve to no slot) and
  anchors it on the same `localHmAsUtc` the projector and worker use.
  Off-slot and as-needed writes resolve to null and keep insert semantics.
- The per-med and bulk routes look up the existing row at the canonical
  `(userId, medicationId, scheduledFor)` ignoring source and update it in
  place — mirroring the status-toggle route — instead of inserting a
  sibling. The bulk per-entry response gains an additive `updated` status.
- The reminder worker's missed-dose mint now skips a slot that already
  carries an actioned row (taken or skipped) from any source, not only an
  existing pending REMINDER row.

PRN / as-needed writes are unaffected. Regression tests pin the collapse
(exact and one-minute drift), the PRN exemption, and the worker guard.
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 08:06:37 +02:00
1 parent 4578078cc1
commit 0d7d08f616
11 files changed
+1033 -52

No files matched your search

@@ -26,6 +26,10 @@ vi.mock("@/lib/db", () => ({
prisma: {
medication: {
findUnique: vi.fn(),
// v1.8.2 — the slot-snap resolver loads the medication via
// findFirst; default to a schedule-less med so the resolver returns
// null (unscheduled path) and the original insert behaviour holds.
findFirst: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
@@ -307,6 +311,16 @@ describe("POST /api/medications/[id]/intake — one-shot lifecycle", () => {
skipped: false,
};
vi.mocked(prisma.$transaction).mockResolvedValue([createdEvent] as never);
// v1.8.2 — slot-snap resolver loads the med; no schedules → null slot
// → original insert + dedup path preserved.
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce({
id: "med-1",
startsOn: null,
endsOn: null,
oneShot: true,
createdAt: new Date(),
schedules: [],
} as never);
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce(
null as never, // dedup probe
);
+108 -21
View File
@@ -19,6 +19,7 @@ import { reconcileOneShotState } from "@/lib/medications/lifecycle";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
import { resolveSlotInstantForWrite } from "@/lib/medications/scheduling/slot-upsert";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
@@ -74,6 +75,29 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
const { scheduledFor, takenAt, skipped, idempotencyKey } = parsed.data;
const resolvedTakenAt = skipped ? null : (takenAt ?? new Date());
const incomingScheduledFor = scheduledFor ?? takenAt ?? new Date();
// v1.8.2 — source-agnostic slot snap. A twice-daily med carries a
// pending REMINDER row the projector/worker minted at the canonical
// `localHmAsUtc` slot instant. Without this, a manual "Genommen" write
// (source WEB) inserted a SECOND row for the slot because the unique
// key includes `source` and the iOS-vs-server `scheduledFor` can drift
// by a minute — inflating compliance to 100% and suppressing the
// "take now" prompt for a dose the user hadn't taken. Snap the write
// to the canonical slot and update the existing row in place.
//
// Resolved BEFORE the idempotency/dedup window so a scheduled dose
// routes through the slot upsert (which is itself the dedup): the
// legacy 60-second window would otherwise short-circuit by returning
// the slot's pending REMINDER row WITHOUT applying the user's takenAt.
const canonicalSlot = await resolveSlotInstantForWrite({
userId: user.id,
medicationId: id,
userTz: user.timezone,
incoming: incomingScheduledFor,
});
// Idempotency check (explicit key or server-side dedup window)
if (idempotencyKey) {
const existing = await prisma.medicationIntakeEvent.findFirst({
@@ -86,8 +110,10 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
if (existing) {
return apiSuccess(existing);
}
} else {
// Server-side dedup: prevent double-logging within 60 seconds
} else if (!canonicalSlot) {
// Unscheduled / PRN only — the slot upsert handles dedup for
// scheduled doses by collapsing onto the canonical slot row.
// Server-side dedup: prevent double-logging within 60 seconds.
const recentDuplicate = await prisma.medicationIntakeEvent.findFirst({
where: {
userId: user.id,
@@ -102,28 +128,89 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
}
}
const [event] = await prisma.$transaction([
prisma.medicationIntakeEvent.create({
data: {
let event;
if (canonicalSlot) {
// Scheduled dose — find the slot row regardless of `source` (the
// pending REMINDER row, or any prior row for this slot) and update
// it in place; mirrors the status-toggle route. Falls through to a
// create when the slot has no row yet.
const existingSlotRow = await prisma.medicationIntakeEvent.findFirst({
where: {
userId: user.id,
medicationId: id,
scheduledFor: scheduledFor ?? takenAt ?? new Date(),
takenAt: skipped ? null : (takenAt ?? new Date()),
skipped,
source: "WEB",
idempotencyKey: idempotencyKey ?? null,
scheduledFor: canonicalSlot,
deletedAt: null,
},
}),
// Reset snooze when medication is taken
...(!skipped
? [
prisma.medication.update({
where: { id },
data: { snoozedUntil: null },
}),
]
: []),
]);
});
if (existingSlotRow) {
[event] = await prisma.$transaction([
prisma.medicationIntakeEvent.update({
where: { id: existingSlotRow.id },
data: {
takenAt: resolvedTakenAt,
skipped,
syncVersion: { increment: 1 },
idempotencyKey:
idempotencyKey ?? existingSlotRow.idempotencyKey ?? null,
},
}),
...(!skipped
? [
prisma.medication.update({
where: { id },
data: { snoozedUntil: null },
}),
]
: []),
]);
} else {
[event] = await prisma.$transaction([
prisma.medicationIntakeEvent.create({
data: {
userId: user.id,
medicationId: id,
scheduledFor: canonicalSlot,
takenAt: resolvedTakenAt,
skipped,
source: "WEB",
idempotencyKey: idempotencyKey ?? null,
},
}),
...(!skipped
? [
prisma.medication.update({
where: { id },
data: { snoozedUntil: null },
}),
]
: []),
]);
}
} else {
// Unscheduled / PRN / off-slot — keep the original insert behaviour.
[event] = await prisma.$transaction([
prisma.medicationIntakeEvent.create({
data: {
userId: user.id,
medicationId: id,
scheduledFor: incomingScheduledFor,
takenAt: resolvedTakenAt,
skipped,
source: "WEB",
idempotencyKey: idempotencyKey ?? null,
},
}),
// Reset snooze when medication is taken
...(!skipped
? [
prisma.medication.update({
where: { id },
data: { snoozedUntil: null },
}),
]
: []),
]);
}
// v1.4.25 W19b — pen-inventory dose decrement. Only fires for
// non-skipped intakes; a skipped event is not a consumption event.
+75 -18
View File
@@ -47,6 +47,7 @@ import {
recomputeMedicationComplianceForDay,
dayKeyForScheduledFor,
} from "@/lib/rollups/medication-compliance-rollups";
import { resolveSlotInstantForWrite } from "@/lib/medications/scheduling/slot-upsert";
const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
@@ -70,7 +71,12 @@ const bulkPayloadSchema = z.object({
entries: z.array(bulkEntrySchema).min(1).max(MAX_ENTRIES_PER_BATCH),
});
type EntryStatus = "inserted" | "duplicate" | "skipped";
// v1.8.2 — `updated` joins the per-entry status vocabulary: a write that
// snaps onto an existing scheduled-slot row (e.g. the pending REMINDER
// row) updates it in place rather than inserting. The iOS sync engine
// treats `updated` the same as `inserted` for cursor advancement — both
// mean "the server accepted this entry and produced a row id".
type EntryStatus = "inserted" | "updated" | "duplicate" | "skipped";
interface EntryResult {
index: number;
status: EntryStatus;
@@ -154,6 +160,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
const results: EntryResult[] = [];
let inserted = 0;
let updated = 0;
let duplicates = 0;
const skipped: Array<{ index: number; reason: string }> = [];
// v1.4.39 W-MED — collect distinct `(medicationId, dayKey)` pairs
@@ -173,23 +180,70 @@ async function postBulk(request: NextRequest): Promise<Response> {
}
try {
const scheduledFor = entry.scheduledFor ?? new Date();
// The idempotencyKey, when supplied, has a UNIQUE index. A
// re-submission of the same key returns the existing row via
// P2002 → status: "duplicate".
const row = await prisma.medicationIntakeEvent.create({
data: {
userId: user.id,
medicationId: entry.medicationId,
scheduledFor,
takenAt: entry.takenAt ?? null,
skipped: entry.skipped,
source: "API",
idempotencyKey: entry.idempotencyKey ?? null,
},
const incomingScheduledFor =
entry.scheduledFor ?? entry.takenAt ?? new Date();
// v1.8.2 — source-agnostic slot snap. iOS posts the "Genommen"
// reminder action here (source API); without this it inserted a
// SECOND row for a slot already carrying the projector/worker's
// pending REMINDER row (the unique key includes `source`, and the
// iOS-vs-server `scheduledFor` drifts by a minute). Snap onto the
// canonical slot instant and update the existing row in place.
const canonicalSlot = await resolveSlotInstantForWrite({
userId: user.id,
medicationId: entry.medicationId,
userTz: user.timezone,
incoming: incomingScheduledFor,
});
inserted += 1;
results.push({ index: i, status: "inserted", id: row.id });
const scheduledFor = canonicalSlot ?? incomingScheduledFor;
const existingSlotRow = canonicalSlot
? await prisma.medicationIntakeEvent.findFirst({
where: {
userId: user.id,
medicationId: entry.medicationId,
scheduledFor: canonicalSlot,
deletedAt: null,
},
select: { id: true, idempotencyKey: true },
})
: null;
if (existingSlotRow) {
// Scheduled dose with an existing slot row — update in place
// (keep the row's original `source`), report "updated".
const row = await prisma.medicationIntakeEvent.update({
where: { id: existingSlotRow.id },
data: {
takenAt: entry.takenAt ?? null,
skipped: entry.skipped,
syncVersion: { increment: 1 },
idempotencyKey:
entry.idempotencyKey ?? existingSlotRow.idempotencyKey ?? null,
},
});
updated += 1;
results.push({ index: i, status: "updated", id: row.id });
} else {
// No existing slot row (fresh scheduled dose, or unscheduled /
// PRN) — insert. The idempotencyKey, when supplied, has a UNIQUE
// index; a re-submission returns the existing row via P2002 →
// status: "duplicate".
const row = await prisma.medicationIntakeEvent.create({
data: {
userId: user.id,
medicationId: entry.medicationId,
scheduledFor,
takenAt: entry.takenAt ?? null,
skipped: entry.skipped,
source: "API",
idempotencyKey: entry.idempotencyKey ?? null,
},
});
inserted += 1;
results.push({ index: i, status: "inserted", id: row.id });
}
const dayKey = dayKeyForScheduledFor(scheduledFor, user.timezone);
touchedDays.set(`${entry.medicationId}|${dayKey}`, {
medicationId: entry.medicationId,
@@ -231,6 +285,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
details: {
processed: entries.length,
inserted,
updated,
duplicates,
skipped: skipped.length,
},
@@ -241,6 +296,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
meta: {
processed: entries.length,
inserted,
updated,
duplicates,
skipped: skipped.length,
},
@@ -249,7 +305,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches when at least one row landed so the next read reflects the
// ingested batch.
if (inserted > 0) {
if (inserted > 0 || updated > 0) {
invalidateUserMedications(user.id);
}
@@ -282,6 +338,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
return apiSuccess({
processed: entries.length,
inserted,
updated,
duplicates,
skipped,
entries: results,