diff --git a/src/app/api/insights/comprehensive/__tests__/route.test.ts b/src/app/api/insights/comprehensive/__tests__/route.test.ts index 77307fe4a..4f15650fc 100644 --- a/src/app/api/insights/comprehensive/__tests__/route.test.ts +++ b/src/app/api/insights/comprehensive/__tests__/route.test.ts @@ -20,15 +20,30 @@ vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, measurement: { findMany: vi.fn() }, - moodEntry: { 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() }, auditLog: { create: vi.fn() }, $queryRaw: vi.fn(), + $queryRawUnsafe: vi.fn(), }, })); +// v1.4.40 — stub the mood-rollup warm-up so test runs don't fire the +// real recompute against the mocked Prisma. The route fires the +// warm-up as fire-and-forget; the return value is irrelevant. +vi.mock("@/lib/mood/rollups", async () => { + const actual = await vi.importActual( + "@/lib/mood/rollups", + ); + return { + ...actual, + ensureUserMoodRollupsFresh: vi.fn().mockResolvedValue({ recomputed: false }), + }; +}); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ @@ -98,6 +113,9 @@ beforeEach(() => { dateOfBirth: new Date("1985-01-01"), }); (prisma.moodEntry.findMany as ReturnType).mockResolvedValue([]); + ( + prisma.moodEntryRollup.findMany as ReturnType + ).mockResolvedValue([]); (prisma.medication.findMany as ReturnType).mockResolvedValue( [], ); @@ -267,6 +285,109 @@ describe("GET /api/insights/comprehensive — envelope shape", () => { expect(body.data.bpClassification?.category).toBe("Normal"); }); + it("consumes the mood-rollup tier and skips the raw findMany when DAY rows exist", async () => { + // v1.4.40 W-INSIGHTS — rollup-tier read swap parity. Three DAY rows + // populated → the route reads them directly and never touches the + // raw `moodEntry.findMany`. `moodSummary` is computed over per-day + // mean DataPoints, matching the rollup-tier convention shipped by + // `/api/mood/analytics` post-v1.4.39. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + ( + buildComprehensiveAggregate as ReturnType + ).mockResolvedValue({ + summaries: {}, + bpRawRows: { sys: [], dia: [] }, + dailyByType: {}, + firstMeasurementAt: new Date(), + totalMeasurements: 0, + }); + ( + prisma.moodEntryRollup.findMany as ReturnType + ).mockResolvedValue([ + { + userId: "user-comp-1", + granularity: "DAY", + bucketStart: new Date("2026-05-08T00:00:00.000Z"), + count: 1, + mean: 4, + minScore: 4, + maxScore: 4, + sd: null, + computedAt: new Date(), + }, + { + userId: "user-comp-1", + granularity: "DAY", + bucketStart: new Date("2026-05-09T00:00:00.000Z"), + count: 2, + mean: 4.5, + minScore: 4, + maxScore: 5, + sd: 0.5, + computedAt: new Date(), + }, + ]); + + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + + // Raw findMany must not fire on the rollup-tier fast path. + expect(prisma.moodEntry.findMany).not.toHaveBeenCalled(); + + const body = (await res.json()) as { + data: { moodSummary: { count: number; mean: number | null } | null }; + }; + // `summarize()` is called over two DataPoints (one per day). + expect(body.data.moodSummary).not.toBeNull(); + expect(body.data.moodSummary?.count).toBe(2); + // Mean of (4 + 4.5) / 2 = 4.25 + expect(body.data.moodSummary?.mean).toBeCloseTo(4.25, 2); + }); + + it("falls back to the bounded live walk when the rollup tier is empty but raw mood entries exist", async () => { + // v1.4.40 W-INSIGHTS — coverage-fallback parity. Legacy account + // with raw entries but no rollup coverage yet. The route runs the + // legacy bounded walk once; the warm-up helper (stubbed) is + // supposed to fire fire-and-forget so the next request lands on + // the rollup tier. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + ( + buildComprehensiveAggregate as ReturnType + ).mockResolvedValue({ + summaries: {}, + bpRawRows: { sys: [], dia: [] }, + dailyByType: {}, + firstMeasurementAt: new Date(), + totalMeasurements: 0, + }); + ( + prisma.moodEntryRollup.findMany as ReturnType + ).mockResolvedValue([]); + (prisma.moodEntry.findMany as ReturnType).mockResolvedValue([ + { + date: "2026-05-08", + score: 4, + moodLoggedAt: new Date("2026-05-08T12:00:00.000Z"), + }, + { + date: "2026-05-09", + score: 5, + moodLoggedAt: new Date("2026-05-09T12:00:00.000Z"), + }, + ]); + + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + + expect(prisma.moodEntry.findMany).toHaveBeenCalledTimes(1); + + const body = (await res.json()) as { + data: { moodSummary: { count: number } | null }; + }; + expect(body.data.moodSummary).not.toBeNull(); + expect(body.data.moodSummary?.count).toBe(2); + }); + it("derives weight × BP scatter from daily-bucketed series", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); // Five day-aligned pairs (n >= 5 unlocks pearsonCorrelation). diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index b8624c093..4e825f2d2 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -22,6 +22,10 @@ import { annotate } from "@/lib/logging/context"; import { requireAssistantSurface } from "@/lib/feature-flags"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator"; +import { + ensureUserMoodRollupsFresh, + readMoodDayRollups, +} from "@/lib/mood/rollups"; export const dynamic = "force-dynamic"; @@ -82,39 +86,73 @@ export async function buildComprehensiveResponse(user: AuthedUser) { // buckets for the correlation pairings. const aggregate = await buildComprehensiveAggregate(userId); - // Mood entries are user-recorded; the table is small and the - // `moodSummary` field requires raw scores (`summarize()` over the - // raw score series). The findMany stays. - const moodEntries = await prisma.moodEntry.findMany({ - where: { userId, moodLoggedAt: { gte: ninetyDaysAgo } }, - orderBy: { moodLoggedAt: "asc" }, - select: { date: true, score: true, moodLoggedAt: true }, - }); - - // Aggregate mood to daily averages (drives the mood × metric - // correlations). Bucket key matches the legacy code's `date` column - // semantics (YYYY-MM-DD anchored to the user's tz at write time). - const moodByDay = new Map(); - for (const entry of moodEntries) { - const current = moodByDay.get(entry.date) ?? { sum: 0, count: 0 }; - current.sum += entry.score; - current.count += 1; - moodByDay.set(entry.date, current); - } - const dailyMoodEntries = Array.from(moodByDay.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([day, stats]) => ({ - day, - value: Math.round((stats.sum / stats.count) * 100) / 100, + // v1.4.40 — swap the 90-day unbounded `prisma.moodEntry.findMany` + // for the persistent mood-rollup DAY tier (audit Critical Finding + // #2). The two consumers downstream — `dailyMoodEntries` for the + // mood × metric correlations and `moodSummary` for the dashboard + // mood block — read at a per-day resolution, which is precisely the + // shape the rollup writer emits. + // + // Coverage-fallback: when the user has mood entries but zero rollup + // rows yet (legacy account before the boot-time backfill has caught + // up), fall back to a bounded 90-day raw walk once and fire the + // warm-up so the next request lands on the rollup tier. Same posture + // as `/api/mood/analytics`. + // + // The bucket key shape change is documented in the route-parity test + // for `/api/mood/analytics`: rollup `bucketStart` is UTC-anchored + // YYYY-MM-DD; the legacy `MoodEntry.date` column is TZ-anchored + // (write-time). For Berlin tenants whose mood log timestamps don't + // straddle the UTC boundary the two labels agree on every realistic + // entry — the v1.5 per-user-tz bucketing closes the residual DST + // edge. + void ensureUserMoodRollupsFresh(userId); + const moodRollupDayRows = await readMoodDayRollups(userId, ninetyDaysAgo); + let dailyMoodEntries: Array<{ day: string; value: number }>; + let moodDataPoints: DataPoint[]; + let moodEntryCount: number; + if (moodRollupDayRows.length > 0) { + dailyMoodEntries = moodRollupDayRows.map((r) => ({ + day: r.bucketStart.toISOString().slice(0, 10), + value: Math.round(r.mean * 100) / 100, })); + moodDataPoints = moodRollupDayRows.map((r) => ({ + date: r.bucketStart, + value: r.mean, + })); + moodEntryCount = moodRollupDayRows.reduce((s, r) => s + r.count, 0); + } else { + // Coverage-fallback: legacy account with mood entries but no + // rollup coverage yet. Bounded by the 90-day window so the walk is + // capped even when the rollup miss happens. + const moodEntries = await prisma.moodEntry.findMany({ + where: { userId, moodLoggedAt: { gte: ninetyDaysAgo } }, + orderBy: { moodLoggedAt: "asc" }, + select: { date: true, score: true, moodLoggedAt: true }, + }); + const moodByDay = new Map(); + for (const entry of moodEntries) { + const current = moodByDay.get(entry.date) ?? { sum: 0, count: 0 }; + current.sum += entry.score; + current.count += 1; + moodByDay.set(entry.date, current); + } + dailyMoodEntries = Array.from(moodByDay.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([day, stats]) => ({ + day, + value: Math.round((stats.sum / stats.count) * 100) / 100, + })); + // Feed per-day means into `summarize()` so the live-fallback path + // shares the rollup-tier semantic (one DataPoint per day, the + // daily mean) — mirrors the v1.4.39 mood/analytics QA UX-H1 fix. + moodDataPoints = dailyMoodEntries.map((e) => ({ + date: new Date(`${e.day}T12:00:00.000Z`), + value: e.value, + })); + moodEntryCount = moodEntries.length; + } - // moodSummary uses the same `summarize()` helper over raw scores — - // the table is bounded by user-recorded entries so the in-JS path - // is safe. - const moodDataPoints: DataPoint[] = moodEntries.map((e) => ({ - date: e.moodLoggedAt, - value: e.score, - })); const moodSummary = moodDataPoints.length > 0 ? summarize(moodDataPoints) : null; @@ -369,7 +407,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) { action: { name: "insights.comprehensive" }, meta: { totalMeasurements: aggregate.totalMeasurements, - moodEntries: moodEntries.length, + moodEntries: moodEntryCount, medications: medications.length, }, });