From 7c0a7223092ed40b58f2f44f236915fc39379030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 11:17:48 +0200 Subject: [PATCH] fix(medications): backfill today-intakes inside dashboard summary route Dashboard tile feeding the iOS Compliance ring + Erfassen-sheet preview pulls from /api/dashboard/summary, not /api/medications/intake. Without the same projection-then-backfill the intake route received in PR #191, the dashboard endpoint returned [] for daily meds (daysOfWeek = null), keeping the iOS Dashboard tile in its "Heute nichts geplant" empty state even after the intake route fix shipped. Re-uses expandTodayIntakes(schedules, now, tz) + an idempotent createMany with skipDuplicates: true so a concurrent intake-route hit can't race a duplicate row in before the existence probe converges. Two tests added: backfill-mints-once + idempotent-on-second-pass. --- .../dashboard/summary/__tests__/route.test.ts | 156 +++++++++++++++++- src/app/api/dashboard/summary/route.ts | 111 ++++++++++++- 2 files changed, 259 insertions(+), 8 deletions(-) diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index 105c3420d..07d6b4717 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -4,7 +4,20 @@ import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ prisma: { measurement: { groupBy: vi.fn() }, - medicationIntakeEvent: { findMany: vi.fn() }, + medicationIntakeEvent: { + findMany: vi.fn(), + // v1.4.39 W-SERVER-FIX-2 — the dashboard route now backfills + // missing today-rows for daily schedules (parity with the + // intake route's `expandTodayIntakes` projection) so the iOS + // Dashboard compliance tile leaves "Heute nichts geplant" the + // moment a daily med is configured. + createMany: vi.fn(), + }, + medication: { + // v1.4.39 W-SERVER-FIX-2 — projection source for the today's- + // intakes backfill. + findMany: vi.fn(), + }, // v1.4.38 W-F — the route now uses `$queryRaw` for the per-type // latest reading, the 7-day rollup-bucket sparkline, and the // 365-day distinct-day streak set. Three raw calls per request, @@ -72,6 +85,13 @@ beforeEach(() => { vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( [] as never, ); + // v1.4.39 W-SERVER-FIX-2 — default the projection prisma reads to + // "no active medications" so the legacy tests stay in their original + // branch (empty today bucket, no backfill firing). + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.createMany).mockResolvedValue({ + count: 0, + } as never); }); describe("GET /api/dashboard/summary", () => { @@ -293,6 +313,140 @@ describe("GET /api/dashboard/summary", () => { expect(steps?.sparkline).toEqual([8000]); }); + it("backfills today's intakes for daily meds (v1.4.39 W-SERVER-FIX-2)", async () => { + // The dashboard compliance tile is fed by `todaysIntakes`, the + // same window the intake route covers. Pre-fix this only read the + // pre-existing `MedicationIntakeEvent` rows, so a daily med + // (`daysOfWeek: null`) had `scheduledToday=0` until the reminder + // worker minted the RED-phase row at the end of the dose window. + // Post-fix the route projects active schedules through + // `expandTodayIntakes` + idempotently backfills missing rows, so + // the iOS Dashboard tile leaves "Heute nichts geplant" the moment + // a daily med is configured. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + { + id: "med-ramipril", + schedules: [ + { + id: "sched-morgens", + medicationId: "med-ramipril", + windowStart: "07:00", + windowEnd: "09:00", + daysOfWeek: null, + }, + ], + }, + ] as never); + // The dashboard route now issues three findMany calls in the + // today path: + // 1. existence probe (no `OR`) → empty (no rows yet) + // 2. final read after backfill (no `OR`) → one minted row + // 3. streak fetch (with `OR`) → empty + vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation((( + args: unknown, + ) => { + const a = args as { + where: { OR?: unknown }; + select?: { id?: boolean }; + }; + if (a.where.OR) return Promise.resolve([]) as never; + // No `id` field on the existence-probe `select` → the route + // only asked for `(medicationId, scheduledFor)`. + if (!a.select?.id) return Promise.resolve([]) as never; + return Promise.resolve([ + { + id: "e-new", + takenAt: null, + skipped: false, + }, + ]) as never; + }) as never); + vi.mocked(prisma.medicationIntakeEvent.createMany).mockResolvedValue({ + count: 1, + } as never); + + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { compliance: { scheduledToday: number; takenToday: number } }; + }; + expect(body.data.compliance.scheduledToday).toBe(1); + expect(body.data.compliance.takenToday).toBe(0); + expect(prisma.medicationIntakeEvent.createMany).toHaveBeenCalledTimes(1); + // Race-defense backstop: the createMany call must pass + // `skipDuplicates: true` so a concurrent intake-route hit can't + // race a duplicate row in before the existence probe converges. + const createManyArgs = vi.mocked(prisma.medicationIntakeEvent.createMany) + .mock.calls[0][0] as { + data: Array<{ + userId: string; + medicationId: string; + skipped: boolean; + source: string; + }>; + skipDuplicates?: boolean; + }; + expect(createManyArgs.skipDuplicates).toBe(true); + expect(createManyArgs.data).toEqual([ + expect.objectContaining({ + userId: "user-1", + medicationId: "med-ramipril", + skipped: false, + source: "REMINDER", + }), + ]); + }); + + it("is idempotent on a second pass (v1.4.39 W-SERVER-FIX-2)", async () => { + // When the existence probe already sees a row for the projected + // slot (e.g. the intake route or the reminder worker minted it + // first), the dashboard route's backfill `createMany` must not + // fire — the route simply re-reads + returns. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const scheduledFor = new Date("2026-05-21T05:00:00.000Z"); + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + { + id: "med-ramipril", + schedules: [ + { + id: "sched-morgens", + medicationId: "med-ramipril", + windowStart: "07:00", + windowEnd: "09:00", + daysOfWeek: null, + }, + ], + }, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation((( + args: unknown, + ) => { + const a = args as { + where: { OR?: unknown }; + select?: { id?: boolean }; + }; + if (a.where.OR) return Promise.resolve([]) as never; + if (!a.select?.id) { + // Existence probe — the projected slot already exists. + return Promise.resolve([ + { medicationId: "med-ramipril", scheduledFor }, + ]) as never; + } + return Promise.resolve([ + { id: "e-existing", takenAt: null, skipped: false }, + ]) as never; + }) as never); + + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + expect(prisma.medicationIntakeEvent.createMany).not.toHaveBeenCalled(); + const body = (await res.json()) as { + data: { compliance: { scheduledToday: number; takenToday: number } }; + }; + expect(body.data.compliance.scheduledToday).toBe(1); + }); + it("computes intake compliance for today", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation((( diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 458330162..bf4233277 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -48,6 +48,7 @@ import { getServerTranslator } from "@/lib/i18n/server-translator"; import { defaultLocale, type Locale } from "@/lib/i18n/config"; import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { expandTodayIntakes } from "@/lib/medication-schedule"; const SPARK_DAYS = 7; const STREAK_WINDOW_DAYS = 365; @@ -268,6 +269,98 @@ function buildContext( return { greetingName, locale }; } +/** + * v1.4.39 W-SERVER-FIX-2 — project today's active schedules through + * the canonical `expandTodayIntakes` helper, idempotently backfill any + * missing `MedicationIntakeEvent` rows, and re-read the today-window + * so the caller sees both pre-existing rows + the freshly minted ones. + * + * Mirrors the post-fix behaviour of `/api/medications/intake?scope= + * today` so the iOS Dashboard tile (fed by this route) and the iOS + * Erfassen sheet (fed by the intake route) converge on the same row + * set the moment a daily med becomes active. Without this projection + * the dashboard route returned `[]` for daily meds (`daysOfWeek = null`) + * even after the intake route's projection landed, leaving the iOS + * Dashboard tile in its "Heute nichts geplant" empty state all + * morning. + * + * `skipDuplicates: true` guards against a concurrent intake-route hit + * minting the same `(userId, medicationId, scheduledFor, REMINDER)` + * row in the gap between the existence probe and the `createMany`. + * The schema-level `@@unique` is the structural backstop; this flag + * is the defense-in-depth that keeps the route returning 2xx instead + * of bubbling a constraint violation during the race window. + */ +async function projectAndReadTodaysIntakes( + userId: string, + userTz: string, + todayStart: Date, + todayEnd: Date, +): Promise> { + const activeMedications = await prisma.medication.findMany({ + where: { userId, active: true }, + select: { + id: true, + schedules: { + select: { + id: true, + medicationId: true, + windowStart: true, + windowEnd: true, + daysOfWeek: true, + }, + }, + }, + }); + const projected = expandTodayIntakes( + activeMedications.flatMap((m) => m.schedules), + new Date(), + userTz, + ); + + if (projected.length > 0) { + const existing = await prisma.medicationIntakeEvent.findMany({ + where: { + userId, + scheduledFor: { gte: todayStart, lt: todayEnd }, + }, + select: { medicationId: true, scheduledFor: true }, + }); + const existingKey = new Set( + existing.map((e) => `${e.medicationId}|${e.scheduledFor.toISOString()}`), + ); + const missing = projected.filter( + (p) => + !existingKey.has(`${p.medicationId}|${p.scheduledFor.toISOString()}`), + ); + if (missing.length > 0) { + await prisma.medicationIntakeEvent.createMany({ + data: missing.map((m) => ({ + userId, + medicationId: m.medicationId, + scheduledFor: m.scheduledFor, + takenAt: null, + skipped: false, + // `REMINDER` mirrors the intake-route projection — the same + // `IntakeSource` literal the reminder worker uses for + // RED-phase rows. Doctor-report + analytics filters stay + // byte-stable across both code paths. + source: "REMINDER", + })), + skipDuplicates: true, + }); + } + } + + return prisma.medicationIntakeEvent.findMany({ + where: { + userId, + scheduledFor: { gte: todayStart, lt: todayEnd }, + }, + select: { id: true, takenAt: true, skipped: true }, + }); +} + async function buildDashboardSummary( userId: string, userTz: string, @@ -352,14 +445,18 @@ async function buildDashboardSummary( _max: { measuredAt: true }, }), ), + // v1.4.39 W-SERVER-FIX-2 — the dashboard compliance tile pulled + // `MedicationIntakeEvent` rows for the today-window only. Daily + // schedules (`daysOfWeek: null`) had no row to read until the + // reminder worker entered the RED phase at the end of the dose + // window, so the iOS Dashboard tile fell to "Heute nichts geplant" + // even when the intake route (post-`expandTodayIntakes` fix) was + // already projecting + backfilling correctly. Re-uses the same + // helper + idempotent `createMany` (with `skipDuplicates: true` + // so a concurrent intake-route hit can't race a duplicate row in + // before the existence probe converges). time("todaysIntakes", () => - prisma.medicationIntakeEvent.findMany({ - where: { - userId, - scheduledFor: { gte: todayStart, lt: todayEnd }, - }, - select: { id: true, takenAt: true, skipped: true }, - }), + projectAndReadTodaysIntakes(userId, userTz, todayStart, todayEnd), ), time("streakIntakes", () => prisma.medicationIntakeEvent.findMany({