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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 11:17:48 +02:00
1 parent 28594dc852
commit 7c0a722309
2 files changed
+259 -8

No files matched your search

@@ -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(((
+104 -7
View File
@@ -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<Array<{ id: string; takenAt: Date | null; skipped: boolean }>> {
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({