diff --git a/src/app/api/medications/[id]/intake/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/__tests__/route.test.ts index 4952aa170..9ee266d3f 100644 --- a/src/app/api/medications/[id]/intake/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/route.test.ts @@ -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 ); diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 0e886ac42..7c5df2e66 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -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. diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 699101116..495af3d27 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -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 { 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 { } 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 { details: { processed: entries.length, inserted, + updated, duplicates, skipped: skipped.length, }, @@ -241,6 +296,7 @@ async function postBulk(request: NextRequest): Promise { meta: { processed: entries.length, inserted, + updated, duplicates, skipped: skipped.length, }, @@ -249,7 +305,7 @@ async function postBulk(request: NextRequest): Promise { // 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 { return apiSuccess({ processed: entries.length, inserted, + updated, duplicates, skipped, entries: results, diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index 9bf3f94f0..e87dffd4e 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -15,6 +15,7 @@ import { buildCanonicalSchedule, buildRecurrenceContext, scheduleEmitsInWindow, + shouldMintMissedDoseRow, } from "@/lib/medications/scheduling/worker-helpers"; import { syncUserMeasurements } from "@/lib/withings/sync"; import { syncUserActivity } from "@/lib/withings/sync-activity"; @@ -736,21 +737,23 @@ async function handleReminderCheck(jobs: Job[]) { // RED phase: create missed intake event for this slot. if (currentPhase === "RED") { - // v1.7.0 sync — intentionally NO `deletedAt: null` filter: - // a tombstoned row still occupies the `(userId, medicationId, - // scheduledFor, source)` unique slot, so the missed-dose - // create must treat it as present to avoid a P2002 collision. - const existingMissed = await prisma.medicationIntakeEvent.count({ - where: { - medicationId: med.id, - userId: med.user.id, - scheduledFor: slotScheduledFor, - takenAt: null, - source: "REMINDER", - }, + // v1.8.2 — gate the missed-dose mint through the shared + // guard. It refuses to mint when the slot already carries an + // existing pending REMINDER row (P2002-collision avoidance, + // tombstones included) OR an ACTIONED row (taken / skipped) + // from ANY source. The intake write paths snap a "Genommen" / + // "Übersprungen" write onto the canonical slot instant + // (source-agnostic update), so a user who acted before the + // RED phase opens has a live taken/skipped row at this exact + // slot — minting here would re-create the duplicate the + // write paths just collapsed. + const shouldMint = await shouldMintMissedDoseRow(prisma, { + userId: med.user.id, + medicationId: med.id, + scheduledFor: slotScheduledFor, }); - if (existingMissed === 0) { + if (shouldMint) { await prisma.medicationIntakeEvent.create({ data: { userId: med.user.id, diff --git a/src/lib/medications/scheduling/__tests__/resolve-slot-instant.test.ts b/src/lib/medications/scheduling/__tests__/resolve-slot-instant.test.ts new file mode 100644 index 000000000..4ac498ebc --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/resolve-slot-instant.test.ts @@ -0,0 +1,146 @@ +/** + * v1.8.2 — canonical slot-snap helper tests. + * + * Proves the snap contract the intake write paths depend on: + * - multi-time (07:00 / 19:00): an incoming instant near a slot snaps + * to that slot's canonical `localHmAsUtc` instant; a ±1-minute drift + * still snaps to the same instant (the duplicate-row root cause). + * - single-time: snaps to the one slot. + * - PRN schedule → null (keep insert behaviour, never collapse). + * - off-slot beyond tolerance → null. + * - DST spring-forward day still snaps to the canonical instant. + */ +import { describe, expect, it } from "vitest"; + +import { resolveCanonicalSlotInstant } from "../resolve-slot-instant"; +import type { SlotResolverMedication } from "../resolve-slot-instant"; +import type { WorkerScheduleRow } from "../worker-helpers"; +import { localHmAsUtc } from "@/lib/timezone"; + +const TZ = "Europe/Berlin"; + +function makeSchedule( + overrides: Partial = {}, +): WorkerScheduleRow { + return { + id: "sched-1", + windowStart: "07:00", + windowEnd: "08:00", + daysOfWeek: null, + timesOfDay: [], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + ...overrides, + }; +} + +function makeMedication( + schedules: WorkerScheduleRow[], + overrides: Partial = {}, +): SlotResolverMedication { + return { + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00Z"), + schedules, + ...overrides, + }; +} + +describe("resolveCanonicalSlotInstant — multi-time", () => { + // A daily 07:00 / 19:00 med. 2026-06-15 is CEST (UTC+2), so 07:00 local + // = 05:00Z and 19:00 local = 17:00Z. + const med = makeMedication([ + makeSchedule({ timesOfDay: ["07:00", "19:00"], windowEnd: "07:00" }), + ]); + + it("snaps an exact-07:00 write to the canonical 07:00 slot instant", () => { + const incoming = new Date("2026-06-15T05:00:00.000Z"); // 07:00 CEST + const result = resolveCanonicalSlotInstant({ medication: med, userTz: TZ, incoming }); + const canonical = localHmAsUtc(incoming, TZ, 7, 0); + expect(result?.toISOString()).toBe(canonical.toISOString()); + }); + + it("snaps a +1-minute-drifted 07:00 write to the SAME canonical instant", () => { + const incoming = new Date("2026-06-15T05:01:00.000Z"); // 07:01 CEST + const result = resolveCanonicalSlotInstant({ medication: med, userTz: TZ, incoming }); + const canonical = localHmAsUtc( + new Date("2026-06-15T05:00:00.000Z"), + TZ, + 7, + 0, + ); + expect(result?.toISOString()).toBe(canonical.toISOString()); + }); + + it("snaps a 19:00 write to the canonical 19:00 slot, not the 07:00 slot", () => { + const incoming = new Date("2026-06-15T17:02:00.000Z"); // 19:02 CEST + const result = resolveCanonicalSlotInstant({ medication: med, userTz: TZ, incoming }); + const canonical = localHmAsUtc(incoming, TZ, 19, 0); + expect(result?.toISOString()).toBe(canonical.toISOString()); + }); +}); + +describe("resolveCanonicalSlotInstant — single-time", () => { + it("snaps to the one slot", () => { + const med = makeMedication([ + makeSchedule({ timesOfDay: ["08:00"], windowStart: "08:00", windowEnd: "09:00" }), + ]); + const incoming = new Date("2026-06-15T06:10:00.000Z"); // 08:10 CEST + const result = resolveCanonicalSlotInstant({ medication: med, userTz: TZ, incoming }); + const canonical = localHmAsUtc(incoming, TZ, 8, 0); + expect(result?.toISOString()).toBe(canonical.toISOString()); + }); +}); + +describe("resolveCanonicalSlotInstant — exemptions", () => { + it("returns null for a PRN schedule (as-needed never snaps)", () => { + const med = makeMedication([ + makeSchedule({ scheduleType: "PRN", timesOfDay: [] }), + ]); + const incoming = new Date("2026-06-15T05:00:00.000Z"); + expect( + resolveCanonicalSlotInstant({ medication: med, userTz: TZ, incoming }), + ).toBeNull(); + }); + + it("returns null for a medication with no schedules", () => { + const med = makeMedication([]); + const incoming = new Date("2026-06-15T05:00:00.000Z"); + expect( + resolveCanonicalSlotInstant({ medication: med, userTz: TZ, incoming }), + ).toBeNull(); + }); + + it("returns null for an off-slot write beyond tolerance", () => { + // 07:00 / 19:00 schedule; a 13:00-local write is 4–6h from either + // slot — well outside the ±2h default tolerance → unscheduled. + const med = makeMedication([ + makeSchedule({ timesOfDay: ["07:00", "19:00"], windowEnd: "07:00" }), + ]); + const incoming = new Date("2026-06-15T11:00:00.000Z"); // 13:00 CEST + expect( + resolveCanonicalSlotInstant({ medication: med, userTz: TZ, incoming }), + ).toBeNull(); + }); +}); + +describe("resolveCanonicalSlotInstant — DST spring-forward", () => { + it("snaps a write on the 2026-03-29 spring-forward day to the canonical instant", () => { + // 2026-03-29 is the CET→CEST transition. 08:00 local exists (the + // gap is 02:00→03:00). The canonical instant must match localHmAsUtc. + const med = makeMedication([ + makeSchedule({ timesOfDay: ["08:00"], windowStart: "08:00", windowEnd: "09:00" }), + ]); + const incoming = new Date("2026-03-29T06:05:00.000Z"); // 08:05 CEST + const result = resolveCanonicalSlotInstant({ medication: med, userTz: TZ, incoming }); + const canonical = localHmAsUtc(incoming, TZ, 8, 0); + expect(result?.toISOString()).toBe(canonical.toISOString()); + }); +}); diff --git a/src/lib/medications/scheduling/__tests__/should-mint-missed-dose.test.ts b/src/lib/medications/scheduling/__tests__/should-mint-missed-dose.test.ts new file mode 100644 index 000000000..df3be8930 --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/should-mint-missed-dose.test.ts @@ -0,0 +1,61 @@ +/** + * v1.8.2 — reminder-worker RED-phase mint guard. + * + * The worker must NOT mint a pending REMINDER row for a slot that the + * user has already actioned (taken / skipped) from ANY source, nor for + * a slot that already carries a pending REMINDER row. Pins the + * duplicate-intake fix on the worker side. + */ +import { describe, expect, it, vi } from "vitest"; + +import { shouldMintMissedDoseRow } from "../worker-helpers"; + +const SLOT = { + userId: "u1", + medicationId: "m1", + scheduledFor: new Date("2026-06-15T05:00:00.000Z"), +}; + +/** + * Fake `count` that resolves the two probes in call order: the first + * call is the pending-REMINDER probe, the second is the actioned probe. + */ +function fakeClient(pendingReminder: number, actioned: number) { + const count = vi + .fn<(args: { where: Record }) => Promise>() + .mockResolvedValueOnce(pendingReminder) + .mockResolvedValueOnce(actioned); + return { medicationIntakeEvent: { count } }; +} + +describe("shouldMintMissedDoseRow", () => { + it("mints when the slot is empty (no pending, no actioned row)", async () => { + const client = fakeClient(0, 0); + expect(await shouldMintMissedDoseRow(client, SLOT)).toBe(true); + }); + + it("does NOT mint when a pending REMINDER row already exists", async () => { + const client = fakeClient(1, 0); + expect(await shouldMintMissedDoseRow(client, SLOT)).toBe(false); + // short-circuits before the actioned probe + expect(client.medicationIntakeEvent.count).toHaveBeenCalledTimes(1); + }); + + it("does NOT mint when an actioned (taken) row exists from any source", async () => { + const client = fakeClient(0, 1); + expect(await shouldMintMissedDoseRow(client, SLOT)).toBe(false); + // the actioned probe filters takenAt-set OR skipped, live rows only + const actionedWhere = client.medicationIntakeEvent.count.mock.calls[1]?.[0] + .where as Record; + expect(actionedWhere.deletedAt).toBeNull(); + expect(actionedWhere.OR).toEqual([ + { takenAt: { not: null } }, + { skipped: true }, + ]); + }); + + it("does NOT mint when a skipped row exists at the slot", async () => { + const client = fakeClient(0, 1); + expect(await shouldMintMissedDoseRow(client, SLOT)).toBe(false); + }); +}); diff --git a/src/lib/medications/scheduling/resolve-slot-instant.ts b/src/lib/medications/scheduling/resolve-slot-instant.ts new file mode 100644 index 000000000..07a3728be --- /dev/null +++ b/src/lib/medications/scheduling/resolve-slot-instant.ts @@ -0,0 +1,170 @@ +/** + * v1.8.2 — canonical slot-snap for intake writes. + * + * Closes the duplicate-intake-row bug: a twice-daily med ends up with a + * pending REMINDER row AND a separate taken WEB/API row for the same + * dose slot because the unique key carries `source` and the two + * `scheduledFor` instants can drift by a minute between iOS and the + * server's `localHmAsUtc`. The intake write paths use this helper to + * snap an incoming write to the SAME canonical slot instant the + * projector + reminder worker mint with, so the write resolves to one + * row per slot regardless of `source` or sub-minute drift. + * + * Given a medication's schedules, the user's IANA timezone, and an + * incoming instant (the write's `scheduledFor`, or `takenAt` as a + * fallback), `resolveCanonicalSlotInstant` returns the canonical slot + * instant for that dose — byte-identical to the projector's + * `localHmAsUtc(day, userTz, h, m)` row — or `null` when the dose does + * not map to a scheduled slot (PRN / as-needed / off-slot beyond + * tolerance / cyclic off-week). A `null` result is the caller's signal + * to keep the unmodified insert behaviour. + * + * Pure / synchronous: no DB access. The caller `select`s the schedule + * columns the canonical engine consumes and threads `lastIntakeAt` in. + */ +import { + buildCanonicalSchedule, + buildRecurrenceContext, + type WorkerMedicationRow, + type WorkerScheduleRow, +} from "@/lib/medications/scheduling/worker-helpers"; +import { + occurrencesBetween, + type CanonicalSchedule, + type RecurrenceContext, +} from "@/lib/medications/scheduling/recurrence"; +import { getLocalDateParts, localHmAsUtc } from "@/lib/timezone"; + +const ONE_MINUTE_MS = 60_000; +const ONE_HOUR_MS = 60 * ONE_MINUTE_MS; +const ONE_DAY_MS = 24 * ONE_HOUR_MS; + +/** + * Default snap tolerance when a schedule has no usable window span. ±2h + * is wide enough to absorb the iOS-vs-server `localHmAsUtc` minute drift + * plus a reasonable "I took it a bit late" margin, while staying narrow + * enough that a clearly off-slot manual log (e.g. a 13:00 entry against a + * 07:00 / 19:00 schedule) is treated as unscheduled and keeps its own + * row rather than colliding into the nearest slot. + */ +const DEFAULT_TOLERANCE_MS = 2 * ONE_HOUR_MS; + +/** + * The minimal medication projection the slot resolver needs. Matches the + * worker/projector `select` shape — `oneShot`, the schedule rows, plus + * the recurrence-context anchors. + */ +export interface SlotResolverMedication extends WorkerMedicationRow { + schedules: WorkerScheduleRow[]; +} + +export interface ResolveSlotInput { + medication: SlotResolverMedication; + userTz: string; + /** + * The write's incoming `scheduledFor`, or `takenAt` as a fallback when + * the client omitted `scheduledFor`. The day-in-tz of this instant + * determines which calendar day's slots are enumerated. + */ + incoming: Date; + /** + * Latest non-tombstoned `takenAt` for the medication. Only consulted by + * rolling schedules; pass `null` when unknown / not loaded. + */ + lastIntakeAt?: Date | null; +} + +/** + * Snap `incoming` to the nearest canonical scheduled-slot instant for the + * dose's calendar day (in `userTz`), or return `null` when no slot lands + * within tolerance (treat as unscheduled / PRN — keep insert behaviour). + * + * The returned instant is produced by `localHmAsUtc(day, userTz, h, m)` — + * the exact function the projector (`project-today-intakes.ts`) and the + * reminder worker mint pending rows with — so an existing pending row for + * the slot dedupes on the canonical instant. + */ +export function resolveCanonicalSlotInstant( + input: ResolveSlotInput, +): Date | null { + const { medication, userTz, incoming } = input; + const schedules = medication.schedules ?? []; + if (schedules.length === 0) return null; + + const ctx: RecurrenceContext = buildRecurrenceContext({ + medication, + userTz, + lastIntakeAt: input.lastIntakeAt ?? null, + }); + + // The day in userTz implied by the incoming instant. Enumerate that + // day's slots over a window padded by a day on each side so a + // time-of-day near the local-midnight boundary (and any DST shift) is + // still captured — the projector + worker apply the time-of-day to the + // local day, which can land just outside a naive same-UTC-day window. + const parts = getLocalDateParts(incoming, userTz); + const localDayMidnightUtc = localHmAsUtc(incoming, userTz, 0, 0); + const windowStart = new Date(localDayMidnightUtc.getTime() - ONE_DAY_MS); + const windowEnd = new Date(localDayMidnightUtc.getTime() + 2 * ONE_DAY_MS); + + let best: { at: Date; toleranceMs: number; deltaMs: number } | null = null; + + for (const scheduleRow of schedules) { + const canonical: CanonicalSchedule = buildCanonicalSchedule(scheduleRow); + const occurrences = occurrencesBetween( + canonical, + windowStart, + windowEnd, + ctx, + ); + const tolerance = snapToleranceMs(canonical); + + for (const occ of occurrences) { + // Only consider slots that land on the SAME local calendar day as + // the incoming instant — a slot on the padded neighbouring day must + // not capture a write meant for a different day. + const occParts = getLocalDateParts(occ.at, userTz); + if ( + occParts.year !== parts.year || + occParts.month !== parts.month || + occParts.day !== parts.day + ) { + continue; + } + + const deltaMs = Math.abs(occ.at.getTime() - incoming.getTime()); + if (deltaMs > tolerance) continue; + if (best === null || deltaMs < best.deltaMs) { + best = { at: occ.at, toleranceMs: tolerance, deltaMs }; + } + } + } + + return best ? best.at : null; +} + +/** + * Tolerance = ± half the schedule's window span (so two adjacent slots + * never overlap their capture zones), falling back to ±2h when the + * window is degenerate (`windowStart === windowEnd`) or unparseable. + */ +function snapToleranceMs(schedule: CanonicalSchedule): number { + const startMin = hhmmToMinutes(schedule.windowStart); + const endMin = hhmmToMinutes(schedule.windowEnd); + if (startMin === null || endMin === null) return DEFAULT_TOLERANCE_MS; + let span = endMin - startMin; + if (span < 0) span += 24 * 60; // overnight window + if (span === 0) return DEFAULT_TOLERANCE_MS; + const halfSpanMs = (span / 2) * ONE_MINUTE_MS; + // Never go below the minute-drift floor: even a tight 30-minute window + // must absorb the iOS-vs-server sub-minute `scheduledFor` drift. + return Math.max(halfSpanMs, ONE_MINUTE_MS); +} + +function hhmmToMinutes(hhmm: string): number | null { + const [hStr, mStr] = hhmm.split(":"); + const h = Number(hStr); + const m = Number(mStr); + if (!Number.isFinite(h) || !Number.isFinite(m)) return null; + return h * 60 + m; +} diff --git a/src/lib/medications/scheduling/slot-upsert.ts b/src/lib/medications/scheduling/slot-upsert.ts new file mode 100644 index 000000000..9af67304e --- /dev/null +++ b/src/lib/medications/scheduling/slot-upsert.ts @@ -0,0 +1,110 @@ +/** + * v1.8.2 — shared slot resolution for the intake write paths. + * + * Both `POST /api/medications/[id]/intake` (source WEB) and + * `POST /api/medications/intake/bulk` (source API) must resolve an + * incoming dose write to the canonical scheduled-slot instant so the + * write updates the pending REMINDER row the projector/worker minted + * rather than inserting a second row that differs only by `source` (and + * by a sub-minute `scheduledFor` drift). This module loads the + * medication's schedules + the rolling-anchor `lastIntakeAt` and runs the + * pure `resolveCanonicalSlotInstant` snap. + * + * Returns `null` when the dose does not map to a scheduled slot + * (PRN / off-slot beyond tolerance / cyclic off-week / no schedules) — + * the caller's signal to keep the unmodified insert behaviour. + */ +import type { PrismaClient } from "@/generated/prisma/client"; + +import { prisma as defaultPrisma } from "@/lib/db"; +import { resolveCanonicalSlotInstant } from "@/lib/medications/scheduling/resolve-slot-instant"; +import type { WorkerScheduleRow } from "@/lib/medications/scheduling/worker-helpers"; + +type PrismaLike = Pick; + +const SCHEDULE_SELECT = { + id: true, + windowStart: true, + windowEnd: true, + daysOfWeek: true, + timesOfDay: true, + reminderGraceMinutes: true, + rrule: true, + rollingIntervalDays: true, + scheduleType: true, + cyclicOnWeeks: true, + cyclicOffWeeks: true, +} as const; + +const MEDICATION_SELECT = { + id: true, + startsOn: true, + endsOn: true, + oneShot: true, + createdAt: true, + schedules: { select: SCHEDULE_SELECT }, +} as const; + +export interface ResolveSlotForWriteInput { + userId: string; + medicationId: string; + userTz: string; + /** + * The write's `scheduledFor`, or `takenAt` as the fallback when the + * client omitted `scheduledFor`. + */ + incoming: Date; + /** Inject a Prisma client/tx in tests; defaults to the app client. */ + client?: PrismaLike; +} + +/** + * Resolve the canonical scheduled-slot instant for an intake write, or + * `null` when the dose is unscheduled (PRN / off-slot / no schedules). + * + * Loads the medication's schedule rows (and, when any schedule is + * rolling, the latest non-tombstoned `takenAt` to anchor the next-due + * computation byte-identically to the projector + worker). + */ +export async function resolveSlotInstantForWrite( + input: ResolveSlotForWriteInput, +): Promise { + const client = input.client ?? defaultPrisma; + + const medication = await client.medication.findFirst({ + where: { id: input.medicationId, userId: input.userId }, + select: MEDICATION_SELECT, + }); + if (!medication || medication.schedules.length === 0) return null; + + // Rolling cadence anchors off the last logged intake — fetch it only + // when a schedule actually needs it (mirrors the projector's gate). + let lastIntakeAt: Date | null = null; + if (medication.schedules.some((s) => s.rollingIntervalDays !== null)) { + const lastIntake = await client.medicationIntakeEvent.findFirst({ + where: { + userId: input.userId, + medicationId: input.medicationId, + deletedAt: null, + takenAt: { not: null }, + }, + orderBy: { takenAt: "desc" }, + select: { takenAt: true }, + }); + lastIntakeAt = lastIntake?.takenAt ?? null; + } + + return resolveCanonicalSlotInstant({ + medication: { + id: medication.id, + startsOn: medication.startsOn, + endsOn: medication.endsOn, + oneShot: medication.oneShot, + createdAt: medication.createdAt, + schedules: medication.schedules as WorkerScheduleRow[], + }, + userTz: input.userTz, + incoming: input.incoming, + lastIntakeAt, + }); +} diff --git a/src/lib/medications/scheduling/worker-helpers.ts b/src/lib/medications/scheduling/worker-helpers.ts index 58c09c9a9..6ae899a31 100644 --- a/src/lib/medications/scheduling/worker-helpers.ts +++ b/src/lib/medications/scheduling/worker-helpers.ts @@ -127,3 +127,63 @@ export function scheduleEmitsInWindow( ): boolean { return occurrencesBetween(schedule, windowStart, windowEnd, ctx).length > 0; } + +/** + * Minimal Prisma surface the missed-dose guard needs — just a `count` + * over `MedicationIntakeEvent`. Keeps the helper unit-testable with a + * tiny fake and the worker passing its real client. + */ +interface IntakeCountClient { + medicationIntakeEvent: { + count: (args: { where: Record }) => Promise; + }; +} + +/** + * v1.8.2 — decide whether the reminder worker should mint a RED-phase + * pending `REMINDER` row for the given slot. + * + * Returns `false` (skip the mint) when the slot already carries either: + * - an existing pending `REMINDER` row (P2002-collision avoidance; the + * `deletedAt: null` filter is intentionally omitted because a + * tombstoned REMINDER row still occupies the `(userId, medicationId, + * scheduledFor, source)` unique slot), OR + * - an ACTIONED row — `takenAt` set OR `skipped` — from ANY source, + * restricted to live rows (`deletedAt: null`). The intake write + * paths snap a "Genommen" / "Übersprungen" write onto this exact + * canonical slot instant via a source-agnostic update, so a dose the + * user acted on before the RED phase opens already has a live + * taken/skipped row here. Without this arm the worker would mint a + * pending REMINDER row alongside the user's WEB/API taken row — the + * duplicate-intake bug — because the two differ by `source` and the + * unique index would not collide. + * + * `scheduledFor` must be the canonical `localHmAsUtc` slot instant the + * projector + write paths use, so the existence probes match byte-for-byte. + */ +export async function shouldMintMissedDoseRow( + client: IntakeCountClient, + slot: { userId: string; medicationId: string; scheduledFor: Date }, +): Promise { + const existingPendingReminder = await client.medicationIntakeEvent.count({ + where: { + medicationId: slot.medicationId, + userId: slot.userId, + scheduledFor: slot.scheduledFor, + takenAt: null, + source: "REMINDER", + }, + }); + if (existingPendingReminder > 0) return false; + + const existingActioned = await client.medicationIntakeEvent.count({ + where: { + medicationId: slot.medicationId, + userId: slot.userId, + scheduledFor: slot.scheduledFor, + deletedAt: null, + OR: [{ takenAt: { not: null } }, { skipped: true }], + }, + }); + return existingActioned === 0; +} diff --git a/tests/integration/medication-cadence-worker.test.ts b/tests/integration/medication-cadence-worker.test.ts index c0adee4e5..1bd96e992 100644 --- a/tests/integration/medication-cadence-worker.test.ts +++ b/tests/integration/medication-cadence-worker.test.ts @@ -41,6 +41,7 @@ import { buildRecurrenceContext, scheduleEmitsInWindow, } from "@/lib/medications/scheduling/worker-helpers"; +import { localHmAsUtc } from "@/lib/timezone"; const TEST_USER_ID = "user-medication-cadence-worker"; @@ -712,3 +713,77 @@ describe("legacy daysOfWeek fallback", () => { expect(scheduleEmitsInWindow(schedule, ctx, on.start, on.end)).toBe(true); }); }); + +// ──────────────────────────────────────────────────────────────────── +// 8. v1.8.2 — per-med intake POST collapses onto the canonical slot +// ──────────────────────────────────────────────────────────────────── + +describe("v1.8.2 per-med intake POST — source-agnostic slot collapse", () => { + it("updates a pre-existing pending REMINDER slot row instead of inserting a second WEB row", async () => { + const prisma = getPrismaClient(); + const med = await prisma.medication.create({ + data: { + userId: TEST_USER_ID, + name: "Bisoprolol", + dose: "2.5mg", + active: true, + schedules: { + create: { + windowStart: "07:00", + windowEnd: "07:00", + timesOfDay: ["07:00"], + daysOfWeek: null, + scheduleType: "SCHEDULED", + }, + }, + }, + }); + + const slot = localHmAsUtc(new Date(), "Europe/Berlin", 7, 0); + const pending = await prisma.medicationIntakeEvent.create({ + data: { + userId: TEST_USER_ID, + medicationId: med.id, + scheduledFor: slot, + takenAt: null, + skipped: false, + source: "REMINDER", + }, + }); + + const session = await prisma.session.create({ + data: { + userId: TEST_USER_ID, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); + + const { POST } = await import("@/app/api/medications/[id]/intake/route"); + // +1 minute drift between the iOS write and the server's localHmAsUtc. + const drifted = new Date(slot.getTime() + 60_000); + const res = await POST( + new NextRequest(`http://localhost/api/medications/${med.id}/intake`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + scheduledFor: drifted.toISOString(), + takenAt: drifted.toISOString(), + skipped: false, + }), + }), + { params: Promise.resolve({ id: med.id }) }, + ); + expect(res.status).toBe(201); + const json = (await res.json()) as { data: { id: string } }; + expect(json.data.id).toBe(pending.id); // updated the SAME row + + const rows = await prisma.medicationIntakeEvent.findMany({ + where: { userId: TEST_USER_ID, medicationId: med.id }, + }); + expect(rows).toHaveLength(1); + expect(rows[0]?.id).toBe(pending.id); + expect(rows[0]?.takenAt).not.toBeNull(); + expect(rows[0]?.scheduledFor.toISOString()).toBe(slot.toISOString()); + }); +}); diff --git a/tests/integration/medications-intake-bulk.test.ts b/tests/integration/medications-intake-bulk.test.ts index bacd36511..5a4f96f2c 100644 --- a/tests/integration/medications-intake-bulk.test.ts +++ b/tests/integration/medications-intake-bulk.test.ts @@ -12,6 +12,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { cookieJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; +import { localHmAsUtc } from "@/lib/timezone"; const TEST_USER_ID = "user-medications-intake-bulk"; const OTHER_USER_ID = "user-medications-intake-other"; @@ -160,6 +161,203 @@ describe("POST /api/medications/intake/bulk (real Postgres)", () => { ]); }); + // v1.8.2 — duplicate-intake slot collapse. A scheduled med carries a + // pending REMINDER row at the canonical `localHmAsUtc` slot instant; + // an iOS "Genommen" write (source API) must UPDATE that row, not insert + // a second source-API row that differs only by source + sub-minute + // drift. + describe("v1.8.2 — source-agnostic slot collapse", () => { + const TZ = "Europe/Berlin"; + + async function makeScheduledMed(timesOfDay: string[]): Promise { + const prisma = getPrismaClient(); + const med = await prisma.medication.create({ + data: { + userId: TEST_USER_ID, + name: "Ramipril", + dose: "5mg", + active: true, + schedules: { + create: { + windowStart: timesOfDay[0], + windowEnd: timesOfDay[0], + timesOfDay, + daysOfWeek: null, + scheduleType: "SCHEDULED", + }, + }, + }, + }); + return med.id; + } + + it("collapses an API taken-write onto a pre-existing pending REMINDER slot row (exact instant)", async () => { + const prisma = getPrismaClient(); + const medId = await makeScheduledMed(["07:00"]); + // The projector/worker minted this pending REMINDER row. + const slot = localHmAsUtc(new Date(), TZ, 7, 0); + const pending = await prisma.medicationIntakeEvent.create({ + data: { + userId: TEST_USER_ID, + medicationId: medId, + scheduledFor: slot, + takenAt: null, + skipped: false, + source: "REMINDER", + }, + }); + + const { POST } = await import("@/app/api/medications/intake/bulk/route"); + const res = await POST( + makeRequest({ + entries: [ + { + medicationId: medId, + scheduledFor: slot.toISOString(), + takenAt: slot.toISOString(), + }, + ], + }), + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { inserted: number; updated: number; entries: Array<{ status: string }> }; + }; + expect(json.data.inserted).toBe(0); + expect(json.data.updated).toBe(1); + expect(json.data.entries[0]?.status).toBe("updated"); + + const rows = await prisma.medicationIntakeEvent.findMany({ + where: { userId: TEST_USER_ID, medicationId: medId }, + }); + expect(rows).toHaveLength(1); + expect(rows[0]?.id).toBe(pending.id); + expect(rows[0]?.takenAt).not.toBeNull(); + expect(rows[0]?.source).toBe("REMINDER"); // original source preserved + }); + + it("collapses even when the write's scheduledFor drifts by 1 minute", async () => { + const prisma = getPrismaClient(); + const medId = await makeScheduledMed(["07:00"]); + const slot = localHmAsUtc(new Date(), TZ, 7, 0); + await prisma.medicationIntakeEvent.create({ + data: { + userId: TEST_USER_ID, + medicationId: medId, + scheduledFor: slot, + takenAt: null, + skipped: false, + source: "REMINDER", + }, + }); + + const drifted = new Date(slot.getTime() + 60_000); // +1 min + const { POST } = await import("@/app/api/medications/intake/bulk/route"); + const res = await POST( + makeRequest({ + entries: [ + { + medicationId: medId, + scheduledFor: drifted.toISOString(), + takenAt: drifted.toISOString(), + }, + ], + }), + ); + const json = (await res.json()) as { + data: { inserted: number; updated: number }; + }; + expect(json.data.updated).toBe(1); + expect(json.data.inserted).toBe(0); + + const rows = await prisma.medicationIntakeEvent.findMany({ + where: { userId: TEST_USER_ID, medicationId: medId }, + }); + expect(rows).toHaveLength(1); + expect(rows[0]?.scheduledFor.toISOString()).toBe(slot.toISOString()); + }); + + it("does NOT collapse PRN doses — two as-needed logs keep two rows", async () => { + const prisma = getPrismaClient(); + const med = await prisma.medication.create({ + data: { + userId: TEST_USER_ID, + name: "Ibuprofen", + dose: "400mg", + active: true, + schedules: { + create: { + windowStart: "00:00", + windowEnd: "00:00", + timesOfDay: [], + daysOfWeek: null, + scheduleType: "PRN", + }, + }, + }, + }); + + const { POST } = await import("@/app/api/medications/intake/bulk/route"); + const t1 = new Date("2026-06-15T09:00:00.000Z"); + const t2 = new Date("2026-06-15T15:00:00.000Z"); + await POST( + makeRequest({ + entries: [ + { medicationId: med.id, scheduledFor: t1.toISOString(), takenAt: t1.toISOString() }, + ], + }), + ); + const res = await POST( + makeRequest({ + entries: [ + { medicationId: med.id, scheduledFor: t2.toISOString(), takenAt: t2.toISOString() }, + ], + }), + ); + const json = (await res.json()) as { + data: { inserted: number; updated: number }; + }; + expect(json.data.inserted).toBe(1); + expect(json.data.updated).toBe(0); + + const rows = await prisma.medicationIntakeEvent.findMany({ + where: { userId: TEST_USER_ID, medicationId: med.id }, + }); + expect(rows).toHaveLength(2); + }); + + it("creates exactly one row for a fresh scheduled dose with no pre-existing slot row", async () => { + const prisma = getPrismaClient(); + const medId = await makeScheduledMed(["19:00"]); + const slot = localHmAsUtc(new Date(), TZ, 19, 0); + + const { POST } = await import("@/app/api/medications/intake/bulk/route"); + const res = await POST( + makeRequest({ + entries: [ + { + medicationId: medId, + scheduledFor: slot.toISOString(), + takenAt: slot.toISOString(), + }, + ], + }), + ); + const json = (await res.json()) as { + data: { inserted: number; updated: number }; + }; + expect(json.data.inserted).toBe(1); + expect(json.data.updated).toBe(0); + + const rows = await prisma.medicationIntakeEvent.findMany({ + where: { userId: TEST_USER_ID, medicationId: medId }, + }); + expect(rows).toHaveLength(1); + // snapped to the canonical slot instant + expect(rows[0]?.scheduledFor.toISOString()).toBe(slot.toISOString()); + }); + }); + it("returns `duplicate` when an idempotencyKey is re-used", async () => { const { POST } = await import("@/app/api/medications/intake/bulk/route"); const body = {