mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
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(((
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user