From f8de4b05fd222137ff5db2cd387a703f43fa0bff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 11:52:09 +0200 Subject: [PATCH] perf(insights-targets): consume mood-rollup tier and floor distinct measurement scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit Critical Finding #2 + High finding 3 (round-v1439-arch-qa-infra-db). The mood block at the bottom of the route used to run an unbounded `prisma.moodEntry.findMany({ where: { userId } })` on every mount — pulling every mood the user had ever logged into Node only to bucket- average them in JS. The `findMany({distinct:['type']})` for 'absolute latest measurement per type' carried no `measuredAt >=` floor either, so on a 347 k-row tenant Postgres still had to sort the full set before the driver applied DISTINCT ON (Prisma's `distinct` doesn't compile to PG's `DISTINCT ON`). This commit: - swaps the mood findMany for `readMoodDayRollups(userId, since)` plus a single `findFirst` for the latest score; coverage-fallback runs a bounded 30-day raw walk + fires `ensureUserMoodRollupsFresh` so the next request lands on the rollup tier (same posture as /api/mood/analytics post-v1.4.39) - adds a 365-day floor to the latest-ever-per-type read so the driver-side distinct walk stays bounded - pins all three behaviours in `__tests__/route.test.ts` (4 new tests: rollup fast path, coverage-fallback bounded walk, no-mood branch, distinct floor present) --- .../insights/targets/__tests__/route.test.ts | 280 ++++++++++++++++++ src/app/api/insights/targets/route.ts | 158 +++++++--- 2 files changed, 404 insertions(+), 34 deletions(-) create mode 100644 src/app/api/insights/targets/__tests__/route.test.ts diff --git a/src/app/api/insights/targets/__tests__/route.test.ts b/src/app/api/insights/targets/__tests__/route.test.ts new file mode 100644 index 000000000..3aa47268f --- /dev/null +++ b/src/app/api/insights/targets/__tests__/route.test.ts @@ -0,0 +1,280 @@ +/** + * v1.4.40 W-INSIGHTS — `/api/insights/targets` mood-rollup tier swap. + * + * Audit Critical Finding #2 (`.planning/round-v1439-arch-qa-infra-db.md`) + * flagged the unbounded `prisma.moodEntry.findMany({ where: { userId } })` + * at the bottom of the route as one of the six unbounded mood walks + * that paint the Insights surface 12 s cold on a power-user account. + * This file pins: + * + * 1. **The mood-rollup DAY tier replaces the raw findMany when + * populated.** Mood targets (`MOOD_SCORE`, `MOOD_STABILITY`) + * compute their `current` / `average30` / `consistency7d` from + * the rollup row set; `prisma.moodEntry.findMany` is never + * called on the fast path. A single `findFirst` reads the + * latest score (one bounded row). + * + * 2. **The coverage-fallback runs the live walk once when the + * rollup tier is empty but raw entries exist** (legacy account + * before the boot-time backfill has caught up). Bounded by the + * trailing 30-day window so the fallback never repeats the + * legacy unbounded scan. + * + * 3. **The `findMany distinct(["type"])` "latest ever per type" + * query carries a `measuredAt: { gte: oneYearAgo }` floor** + * (audit High finding 3). Without the floor Postgres still has + * to sort the full 347 k-row tenant before applying DISTINCT ON + * because Prisma's `distinct` does not compile to PG's + * `DISTINCT ON`. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + measurement: { findMany: vi.fn() }, + moodEntry: { findMany: vi.fn(), findFirst: vi.fn() }, + moodEntryRollup: { findMany: vi.fn(), findFirst: vi.fn() }, + medication: { findMany: vi.fn() }, + medicationIntakeEvent: { findMany: vi.fn() }, + appSettings: { findUnique: vi.fn() }, + $queryRaw: vi.fn(), + $queryRawUnsafe: vi.fn(), + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +// Stub the warm-up so test runs don't fire the real +// `recomputeUserMoodRollups` aggregate. The warm-up is fire-and-forget +// on the route so its return value is irrelevant for parity. +vi.mock("@/lib/mood/rollups", async () => { + const actual = await vi.importActual( + "@/lib/mood/rollups", + ); + return { + ...actual, + ensureUserMoodRollupsFresh: vi.fn().mockResolvedValue({ recomputed: false }), + }; +}); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; + +const SESSION_USER = { + id: "user-targets-1", + username: "marc", + role: "USER" as const, + timezone: "Europe/Berlin", + heightCm: 180, + dateOfBirth: new Date("1985-01-01"), + locale: "en", +}; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: SESSION_USER as never, +}; + +const callGet = GET as unknown as (req: NextRequest) => Promise; +function makeReq(): NextRequest { + return new NextRequest("http://localhost/api/insights/targets"); +} + +interface TargetsBody { + data: { + targets: Array<{ + type: string; + current: number | null; + average30: number | null; + daysInRange7d: number; + daysLogged7d: number; + insufficientData: boolean; + }>; + }; +} + +beforeEach(() => { + vi.resetAllMocks(); + __resetAllCachesForTests(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK); + (prisma.user.findUnique as ReturnType).mockResolvedValue({ + heightCm: 180, + dateOfBirth: new Date("1985-01-01"), + gender: "MALE", + glucoseUnit: "mg/dL", + thresholdsJson: null, + }); + (prisma.measurement.findMany as ReturnType).mockResolvedValue( + [], + ); + (prisma.medication.findMany as ReturnType).mockResolvedValue( + [], + ); + ( + prisma.medicationIntakeEvent.findMany as ReturnType + ).mockResolvedValue([]); + (prisma.moodEntry.findMany as ReturnType).mockResolvedValue([]); + (prisma.moodEntry.findFirst as ReturnType).mockResolvedValue( + null, + ); + ( + prisma.moodEntryRollup.findMany as ReturnType + ).mockResolvedValue([]); +}); + +describe("GET /api/insights/targets — mood-rollup tier swap", () => { + it("returns a mood target driven by the rollup DAY tier without touching the raw findMany", async () => { + // Five DAY-rollup rows over the trailing 30 days. The route reads + // them directly to drive `MOOD_SCORE.current/average30/consistency` + // — no raw `moodEntry.findMany` on the fast path. + const today = new Date(); + const day = (offset: number) => { + const d = new Date(today); + d.setUTCHours(0, 0, 0, 0); + d.setUTCDate(d.getUTCDate() - offset); + return d; + }; + ( + prisma.moodEntryRollup.findMany as ReturnType + ).mockResolvedValue([ + { userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(0), count: 1, mean: 4, minScore: 4, maxScore: 4, sd: null, computedAt: new Date() }, + { userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(1), count: 1, mean: 4, minScore: 4, maxScore: 4, sd: null, computedAt: new Date() }, + { userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(2), count: 1, mean: 5, minScore: 5, maxScore: 5, sd: null, computedAt: new Date() }, + { userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(3), count: 1, mean: 4, minScore: 4, maxScore: 4, sd: null, computedAt: new Date() }, + { userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(4), count: 1, mean: 4.5, minScore: 4, maxScore: 5, sd: 0.5, computedAt: new Date() }, + ]); + // Latest score read — one bounded row. + (prisma.moodEntry.findFirst as ReturnType).mockResolvedValue({ + score: 4, + moodLoggedAt: day(0), + }); + + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + + // The unbounded raw `moodEntry.findMany` must not fire on the + // rollup-tier fast path. + expect(prisma.moodEntry.findMany).not.toHaveBeenCalled(); + + const body = (await res.json()) as TargetsBody; + const moodTarget = body.data.targets.find((t) => t.type === "MOOD_SCORE"); + expect(moodTarget).toBeDefined(); + expect(moodTarget?.current).toBe(4); + // Average over 5 daily means: (4+4+5+4+4.5)/5 = 4.3 + expect(moodTarget?.average30).toBeCloseTo(4.3, 1); + }); + + it("falls back to the bounded raw walk when the rollup tier is empty but raw mood entries exist", async () => { + // Rollup empty → coverage-fallback fires the legacy live walk + // once. The fallback is bounded by the 30-day window — not the + // legacy unbounded scan over every mood the user ever wrote. + ( + prisma.moodEntryRollup.findMany as ReturnType + ).mockResolvedValue([]); + const today = new Date(); + (prisma.moodEntry.findFirst as ReturnType).mockResolvedValue({ + score: 4, + moodLoggedAt: today, + }); + (prisma.moodEntry.findMany as ReturnType).mockResolvedValue([ + { score: 4, moodLoggedAt: new Date(today.getTime() - 2 * 86_400_000) }, + { score: 5, moodLoggedAt: new Date(today.getTime() - 1 * 86_400_000) }, + { score: 4, moodLoggedAt: today }, + ]); + + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + + // Live fallback fired exactly once. The fallback `findMany` call + // must carry a 30-day `moodLoggedAt: { gte: ... }` floor — i.e. a + // `where` clause with a timestamp filter, not the legacy + // unbounded `where: { userId }` shape. + expect(prisma.moodEntry.findMany).toHaveBeenCalledTimes(1); + const fallbackCall = (prisma.moodEntry.findMany as ReturnType) + .mock.calls[0][0]; + expect(fallbackCall.where).toHaveProperty("moodLoggedAt"); + expect(fallbackCall.where.moodLoggedAt).toHaveProperty("gte"); + + const body = (await res.json()) as TargetsBody; + const moodTarget = body.data.targets.find((t) => t.type === "MOOD_SCORE"); + expect(moodTarget).toBeDefined(); + expect(moodTarget?.current).toBe(4); + }); + + it("emits no mood target when neither rollup nor raw entries exist", async () => { + ( + prisma.moodEntryRollup.findMany as ReturnType + ).mockResolvedValue([]); + (prisma.moodEntry.findFirst as ReturnType).mockResolvedValue( + null, + ); + + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as TargetsBody; + const moodTarget = body.data.targets.find((t) => t.type === "MOOD_SCORE"); + expect(moodTarget).toBeUndefined(); + }); + + it("floors the `findMany distinct` latest-ever-per-type read at one year", async () => { + // Audit High finding 3: the `findMany({orderBy: measuredAt desc, + // distinct: ["type"]})` for "absolute latest measurement per type" + // used to run WITHOUT a `measuredAt >=` floor. On a 347 k-row + // tenant Postgres still has to sort the full set before applying + // DISTINCT ON because Prisma's `distinct` does not compile to PG's + // `DISTINCT ON` — it dedups in the driver. v1.4.40 adds a + // 365-day floor so the planner-side walk is bounded. + (prisma.measurement.findMany as ReturnType).mockResolvedValue( + [], + ); + + await callGet(makeReq()); + + // Find the `distinct: ["type"]` call. + const calls = ( + prisma.measurement.findMany as ReturnType + ).mock.calls.map((c) => c[0]); + const distinctCall = calls.find( + (c) => + c && + Array.isArray(c.distinct) && + c.distinct[0] === "type" && + c.orderBy?.measuredAt === "desc", + ); + expect(distinctCall).toBeDefined(); + // The `measuredAt >= oneYearAgo` floor must be present. + expect(distinctCall?.where?.measuredAt).toBeDefined(); + expect(distinctCall?.where?.measuredAt?.gte).toBeInstanceOf(Date); + const flooredAt = distinctCall?.where?.measuredAt?.gte as Date; + const oneYearMs = 365 * 24 * 60 * 60 * 1000; + const ageMs = Date.now() - flooredAt.getTime(); + // Floor is somewhere around one year ago — give a ±1-hour window + // for clock drift between the route's `Date.now()` and the test + // assertion. + expect(ageMs).toBeGreaterThan(oneYearMs - 3_600_000); + expect(ageMs).toBeLessThan(oneYearMs + 3_600_000); + }); +}); diff --git a/src/app/api/insights/targets/route.ts b/src/app/api/insights/targets/route.ts index 0489c09df..04db0e8cf 100644 --- a/src/app/api/insights/targets/route.ts +++ b/src/app/api/insights/targets/route.ts @@ -33,6 +33,10 @@ import { } from "@/lib/analytics/effective-range"; import { getBodyFatTargetRange } from "@/lib/analytics/value-bands"; import { thresholdMetricForContext, resolveGlucoseUnit } from "@/lib/glucose"; +import { + ensureUserMoodRollupsFresh, + readMoodDayRollups, +} from "@/lib/mood/rollups"; export const dynamic = "force-dynamic"; @@ -184,12 +188,26 @@ async function buildTargetsResponse(user: AuthedUser) { select: { type: true, value: true, measuredAt: true }, }); - // Also get the absolute latest measurement per type (even if older than 30 days). - // Single grouped query replaces the previous N×findFirst loop — DISTINCT ON - // (Postgres) emulated via a windowed orderBy + take-1 per type would still be - // N round-trips, so we sort once on the wide query and pick per-type below. + // Absolute latest measurement per type within a one-year floor. + // + // Prisma's `distinct` does not compile to Postgres `DISTINCT ON` — the + // driver dedups after pulling the rows, so an unbounded scan on a + // 347 k-row tenant pulls every measurement back to Node only to drop + // all but seven (audit Critical Finding #2, v1.4.40). The 365-day + // floor caps the planner-side walk to the existing + // `(user_id, type, measured_at)` index range; the trade-off is that a + // user who hasn't measured a metric in over a year sees the tile's + // `current` value as null instead of pulling a stale year-old reading + // forward — which is the correct behaviour for the consistency strip + // (the strip already gates on "fewer than 3 readings in 30 days" → + // insufficientData). + const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000); const latestEverByType = await prisma.measurement.findMany({ - where: { userId, type: { in: types } }, + where: { + userId, + type: { in: types }, + measuredAt: { gte: oneYearAgo }, + }, orderBy: { measuredAt: "desc" }, distinct: ["type"], select: { type: true, value: true }, @@ -980,41 +998,112 @@ async function buildTargetsResponse(user: AuthedUser) { } // 9. Mood targets (if mood data exists) - const moodEntries = await prisma.moodEntry.findMany({ + // + // v1.4.40 \u2014 swap the unbounded `prisma.moodEntry.findMany({ where: { + // userId } })` for the persistent mood-rollup DAY tier (audit Critical + // Finding #2). The tile reads: + // - per-day mean over the trailing 30 days (avg30, trend, stability) + // - the consistency strip (in/near/out band per day) + // - the latest score for the "current" reading + classification + // + // Rollup row = `{bucketStart, mean, count, minScore, maxScore}` per + // calendar day. Per-day mean replaces the per-entry score series the + // legacy path passed to `rollupConsistency` \u2014 the helper buckets by + // day-key anyway, so feeding one row per day with the pre-computed + // mean is mathematically identical to feeding N raw entries that + // collapse to the same mean. + // + // The "latest score" needs a single raw entry (the newest one), so we + // pull it with `take: 1` \u2014 bounded by the index, never an unbounded + // scan. The "\u2265 3 entries threshold" gate uses the rollup count sum so + // the cold-start branch stays consistent with the legacy semantics + // (which counted raw entries, not raw days). + // + // Coverage-fallback: when the user has mood entries but zero rollup + // rows yet (legacy account before the boot-time backfill caught up) + // we run the legacy live walk once and fire the warm-up so the next + // request lands on the rollup tier. Same posture as + // `/api/mood/analytics`. + void ensureUserMoodRollupsFresh(userId); + const thirtyDaysAgoMood = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + // Five-year window mirrors `/api/mood/analytics` so existing rollup + // coverage is consulted; the trailing 30-day filter happens in JS + // because the consistency strip already walks per-day buckets. + const moodRollupWindowMs = 5 * 365 * 24 * 60 * 60 * 1000; + const moodSince = new Date(Date.now() - moodRollupWindowMs); + const moodRollups = await readMoodDayRollups(userId, moodSince); + const latestMoodEntry = await prisma.moodEntry.findFirst({ where: { userId }, orderBy: { moodLoggedAt: "desc" }, select: { score: true, moodLoggedAt: true }, }); - if (moodEntries.length >= 3) { - const thirtyDaysAgoMood = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - const recentMood = moodEntries.filter( - (entry) => entry.moodLoggedAt >= thirtyDaysAgoMood, - ); - const latestMoodScore = moodEntries[0]?.score ?? null; + // Build a per-day event list for the consistency strip + 30-day stats. + // Use the rollup tier when populated; fall back to a bounded raw walk + // when the user has mood entries but zero rollup coverage yet. + let moodDailyEvents: Array<{ measuredAt: Date; value: number }>; + let moodEntryCount: number; + let moodSumScores: number; + let moodVarianceN: number; + let moodSumSquares: number; + if (moodRollups.length > 0) { + moodEntryCount = moodRollups.reduce((s, r) => s + r.count, 0); + moodDailyEvents = moodRollups + .filter((r) => r.bucketStart >= thirtyDaysAgoMood) + .map((r) => ({ measuredAt: r.bucketStart, value: r.mean })); + moodSumScores = moodDailyEvents.reduce((s, e) => s + e.value, 0); + moodVarianceN = moodDailyEvents.length; + moodSumSquares = moodDailyEvents.reduce((s, e) => s + e.value * e.value, 0); + } else if (latestMoodEntry !== null) { + // Coverage-fallback: rollups empty but raw entries exist. Pull the + // trailing 30-day window only (instead of every entry ever) \u2014 that's + // the bound the legacy code was missing. + const recentRawMood = await prisma.moodEntry.findMany({ + where: { userId, moodLoggedAt: { gte: thirtyDaysAgoMood } }, + orderBy: { moodLoggedAt: "asc" }, + select: { score: true, moodLoggedAt: true }, + }); + moodEntryCount = recentRawMood.length; + moodDailyEvents = recentRawMood.map((e) => ({ + measuredAt: e.moodLoggedAt, + value: e.score, + })); + moodSumScores = recentRawMood.reduce((s, e) => s + e.score, 0); + moodVarianceN = recentRawMood.length; + moodSumSquares = recentRawMood.reduce((s, e) => s + e.score * e.score, 0); + } else { + moodEntryCount = 0; + moodDailyEvents = []; + moodSumScores = 0; + moodVarianceN = 0; + moodSumSquares = 0; + } + + if (moodEntryCount >= 3) { + const latestMoodScore = latestMoodEntry?.score ?? null; const moodAvg30 = - recentMood.length > 0 + moodDailyEvents.length > 0 ? Math.round( - (recentMood.reduce((sum, e) => sum + e.score, 0) / - recentMood.length) * - 10, + (moodSumScores / moodDailyEvents.length) * 10, ) / 10 : null; - // Mood trend: compare first half to second half of last 30 days + // Mood trend: compare first half to second half of last 30 days. + // The rollup tier carries one DataPoint per day (the daily mean), + // matching the legacy comparison semantics on single-entry days. let moodTrend: "up" | "down" | "stable" | null = null; - if (recentMood.length >= 4) { - const sorted = [...recentMood].sort( - (a, b) => a.moodLoggedAt.getTime() - b.moodLoggedAt.getTime(), + if (moodDailyEvents.length >= 4) { + const sorted = [...moodDailyEvents].sort( + (a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(), ); const mid = Math.floor(sorted.length / 2); const firstHalf = sorted.slice(0, mid); const secondHalf = sorted.slice(mid); const avgFirst = - firstHalf.reduce((s, e) => s + e.score, 0) / firstHalf.length; + firstHalf.reduce((s, e) => s + e.value, 0) / firstHalf.length; const avgSecond = - secondHalf.reduce((s, e) => s + e.score, 0) / secondHalf.length; + secondHalf.reduce((s, e) => s + e.value, 0) / secondHalf.length; const diff = avgSecond - avgFirst; if (diff > 0.2) moodTrend = "up"; else if (diff < -0.2) moodTrend = "down"; @@ -1031,12 +1120,8 @@ async function buildTargetsResponse(user: AuthedUser) { : null; const moodRange = { min: 3.5, max: 5 }; - const moodEventsForConsistency = recentMood.map((entry) => ({ - measuredAt: entry.moodLoggedAt, - value: entry.score, - })); const moodConsistency = rollupConsistency( - moodEventsForConsistency, + moodDailyEvents, makeRangeClassifier(moodRange, { orangeMin: 2, orangeMax: 5 }), ); targets.push({ @@ -1052,13 +1137,18 @@ async function buildTargetsResponse(user: AuthedUser) { ...moodConsistency, }); - // Mood stability: standard deviation of recent scores (lower = more stable) - if (recentMood.length >= 5) { - const mean = moodAvg30 ?? latestMoodScore ?? 3; - const variance = - recentMood.reduce((sum, e) => sum + (e.score - mean) ** 2, 0) / - recentMood.length; - const stdDev = Math.round(Math.sqrt(variance) * 100) / 100; + // Mood stability: standard deviation of the recent series. The + // rollup tier surfaces one mean per day, which is the right + // resolution for "is the user's mood stable across days" \u2014 a power + // user logging multiple times per day with a wide intraday spread + // still gets a "stable" badge if the daily mean barely moves. The + // legacy path conflated intraday variance with day-to-day stability + // by passing per-entry scores; the rollup-tier semantic is closer + // to what the UI label suggests. + if (moodVarianceN >= 5) { + const seriesMean = moodSumScores / moodVarianceN; + const variance = moodSumSquares / moodVarianceN - seriesMean * seriesMean; + const stdDev = Math.round(Math.sqrt(Math.max(0, variance)) * 100) / 100; const stabilityClassification = stdDev <= 0.5