perf(insights-comprehensive): consume mood-rollup tier

Audit Critical Finding #2 (round-v1439-arch-qa-infra-db). The 90-day
`prisma.moodEntry.findMany` block fed two downstream consumers:
`dailyMoodEntries` for mood × metric correlations, and `moodSummary`
for the dashboard mood block. Both read at a per-day resolution.

Swap to `readMoodDayRollups(userId, ninetyDaysAgo)` so the route reads
one row per calendar day instead of one per entry. Coverage-fallback
runs a bounded 90-day live walk + fires `ensureUserMoodRollupsFresh`
so the next request lands on the rollup tier (same posture as
/api/mood/analytics post-v1.4.39). `moodSummary` now feeds per-day
mean DataPoints into `summarize()` on both branches, matching the
rollup-tier convention shipped in v1.4.39 (QA UX-H1).

Two parity tests added to `__tests__/route.test.ts`: rollup fast path
skips the raw findMany, and the coverage-fallback runs the live walk
exactly once when rollups are empty.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 11:52:21 +02:00
1 parent f8de4b05fd
commit 45a8399822
2 files changed
+192 -33

No files matched your search

@@ -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<typeof import("@/lib/mood/rollups")>(
"@/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<typeof vi.fn>).mockResolvedValue([]);
(
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([]);
(prisma.medication.findMany as ReturnType<typeof vi.fn>).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<typeof vi.fn>
).mockResolvedValue({
summaries: {},
bpRawRows: { sys: [], dia: [] },
dailyByType: {},
firstMeasurementAt: new Date(),
totalMeasurements: 0,
});
(
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
).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<typeof vi.fn>
).mockResolvedValue({
summaries: {},
bpRawRows: { sys: [], dia: [] },
dailyByType: {},
firstMeasurementAt: new Date(),
totalMeasurements: 0,
});
(
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([]);
(prisma.moodEntry.findMany as ReturnType<typeof vi.fn>).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).
+70 -32
View File
@@ -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<string, { sum: number; count: number }>();
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<string, { sum: number; count: number }>();
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,
},
});