diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 384df52d6..47b100828 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -43,6 +43,7 @@ import { summarizeBackup, type BackupSummary, } from "@/lib/validations/backup"; +import { recomputeUserMoodRollups } from "@/lib/mood/rollups"; export const dynamic = "force-dynamic"; @@ -281,6 +282,11 @@ const handler = apiHandler( const moods = await tx.moodEntry.deleteMany({ where: { userId: ownerId }, }); + // v1.4.39 W-MOOD — wipe the persisted mood rollup partition + // for this owner so the next analytics read doesn't surface + // pre-restore daily means. The fold below mints fresh rows + // from the restored mood entries. + await tx.moodEntryRollup.deleteMany({ where: { userId: ownerId } }); const channels = await tx.notificationChannel.deleteMany({ where: { userId: ownerId }, }); @@ -410,6 +416,52 @@ const handler = apiHandler( return apiError("Restore failed", 500); } + // v1.4.39 W-MOOD — re-fold the mood rollup tier from the just- + // restored entries. Runs outside the transaction so the (5-year) + // fold can't hold a long write lock; best-effort so a populator + // hiccup doesn't undo the restore. The boot-time backfill is the + // safety net — if this fails the next worker boot mints the rows. + if (payload.moodEntries.length > 0) { + try { + await recomputeUserMoodRollups(ownerId, { granularities: ["DAY"] }); + } catch (err) { + annotate({ + meta: { + mood_rollup_restore_failed: true, + mood_rollup_restore_error: + err instanceof Error ? err.message : String(err), + }, + }); + } + } + + // v1.4.39 W-MED — re-fold the medication-compliance rollup tier + // from the restored intake events. The medication delete inside the + // transaction already cascaded the existing rollup partition for + // this owner via the FK, so the fold mints fresh rows. Boot-time + // backfill is the safety net if this best-effort call fails. + if (payload.intakeEvents.length > 0) { + try { + const restoreUser = await prisma.user.findUnique({ + where: { id: ownerId }, + select: { timezone: true }, + }); + await recomputeUserMedicationCompliance( + ownerId, + MEDICATION_COMPLIANCE_BACKFILL_DAYS, + restoreUser?.timezone ?? null, + ); + } catch (err) { + annotate({ + meta: { + medication_compliance_rollup_restore_failed: true, + medication_compliance_rollup_restore_error: + err instanceof Error ? err.message : String(err), + }, + }); + } + } + const summary = summarizeBackup(payload); await auditLog("admin.backups.restore", { diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index 75873b8af..5911656c4 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -17,6 +17,7 @@ import { measurementTypeEnum, glucoseContextEnum, } from "@/lib/validations/measurement"; +import { recomputeUserMoodRollups } from "@/lib/mood/rollups"; // Derived from canonical enum so round-trip export → import covers every // type. Previous hardcoded subset silently dropped 4 of 11 types @@ -128,6 +129,25 @@ export const POST = apiHandler(async (request: NextRequest) => { } } + // v1.4.39 W-MOOD — one bounded re-fold per import is cheaper than + // firing N per-row hooks. The mood rollup tier is keyed on the + // user + day; the fold materialises every touched day from the + // entries we just wrote. Best-effort: an importer hiccup must not + // surface as a 5xx, the rollup is a cache tier. + if (stats.moodEntries > 0) { + try { + await recomputeUserMoodRollups(userId, { granularities: ["DAY"] }); + } catch (err) { + annotate({ + meta: { + mood_rollup_import_failed: true, + mood_rollup_import_error: + err instanceof Error ? err.message : String(err), + }, + }); + } + } + await auditLog("import.upload", { userId, ipAddress: getClientIp(request), diff --git a/src/app/api/integrations/moodlog/webhook/route.ts b/src/app/api/integrations/moodlog/webhook/route.ts index 06fe99eb7..3fda9c2bd 100644 --- a/src/app/api/integrations/moodlog/webhook/route.ts +++ b/src/app/api/integrations/moodlog/webhook/route.ts @@ -8,6 +8,7 @@ import { moodLogWebhookPayloadSchema } from "@/lib/validations/moodlog"; import { readMoodLogSecret } from "@/lib/moodlog-secret"; import { apiHandler } from "@/lib/api-handler"; import { annotate, getEvent } from "@/lib/logging/context"; +import { recomputeMoodBucketsForEntry } from "@/lib/mood/rollups"; export const dynamic = "force-dynamic"; @@ -124,6 +125,19 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); } + + // v1.4.39 W-MOOD — refresh the persisted rollup for the entry's + // bucket. Inline best-effort: a failure here must not block the + // webhook 200 response (the rollup is a cache tier, the source-of- + // truth is the mood_entries write that already committed). + try { + await recomputeMoodBucketsForEntry(user.id, moodLoggedAt); + } catch (rollupErr) { + getEvent()?.addWarning( + "Mood rollup recompute failed: " + + (rollupErr instanceof Error ? rollupErr.message : String(rollupErr)), + ); + } } catch (err) { getEvent()?.addWarning("DB operation failed: " + err); } diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index 3aa4994ee..8eaeacebc 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -15,6 +15,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; import { invalidateUserMood } from "@/lib/cache/invalidate"; +import { recomputeMoodBucketsForEntry } from "@/lib/mood/rollups"; type RouteParams = { params: Promise<{ id: string }> }; @@ -112,6 +113,31 @@ export const PUT = apiHandler( // v1.4.34 IW-G — bust per-user mood + achievements + analytics caches. invalidateUserMood(user.id); + // v1.4.39 W-MOOD — refresh the persisted rollup for the new + // bucket AND the old bucket when the entry's `moodLoggedAt` + // changed. The two recomputes are independent (different + // (user, day) tuples) so we fan them out in parallel. Best- + // effort: rollup failures must not surface as 5xx. + try { + const targets = new Set([entry.moodLoggedAt.getTime()]); + if (existing.moodLoggedAt.getTime() !== entry.moodLoggedAt.getTime()) { + targets.add(existing.moodLoggedAt.getTime()); + } + await Promise.all( + Array.from(targets).map((t) => + recomputeMoodBucketsForEntry(user.id, new Date(t)), + ), + ); + } catch (rollupErr) { + annotate({ + meta: { + mood_rollup_write_failed: true, + mood_rollup_write_error: + rollupErr instanceof Error ? rollupErr.message : String(rollupErr), + }, + }); + } + return apiSuccess({ ...entry, tags: parseTags(entry.tags) }); }, ); @@ -144,6 +170,21 @@ export const DELETE = apiHandler( // v1.4.34 IW-G — bust per-user mood + achievements + analytics caches. invalidateUserMood(user.id); + // v1.4.39 W-MOOD — refresh the persisted rollup for the + // deleted entry's bucket; the recompute helper handles the + // "now-empty day → drop the rollup row" branch internally. + try { + await recomputeMoodBucketsForEntry(user.id, existing.moodLoggedAt); + } catch (rollupErr) { + annotate({ + meta: { + mood_rollup_write_failed: true, + mood_rollup_write_error: + rollupErr instanceof Error ? rollupErr.message : String(rollupErr), + }, + }); + } + return apiSuccess({ deleted: true }); }, ); diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index 54555b71a..69ed1368c 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -43,6 +43,7 @@ import { } from "@/lib/validations/moodlog"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; import { invalidateUserMood } from "@/lib/cache/invalidate"; +import { recomputeMoodBucketsForEntry } from "@/lib/mood/rollups"; const MAX_ENTRIES_PER_BATCH = 500; const BATCH_RATE_LIMIT_MAX = 60; @@ -218,6 +219,42 @@ async function postBulk(request: NextRequest): Promise { invalidateUserMood(user.id); } + // v1.4.39 W-MOOD — refresh the rollup tier for every distinct day + // touched by this batch. The bulk endpoint is an iOS one-shot + // backfill so the batch can span many days; we collapse to the + // unique `(user, dayStart)` set first to bound the recompute count. + // Best-effort: rollup failures must not surface as 5xx. + if (inserted > 0 || duplicates > 0) { + const touchedDayStarts = new Set(); + for (let i = 0; i < entries.length; i++) { + const status = results[i]?.status; + if (status === "inserted" || status === "duplicate") { + const d = entries[i].moodLoggedAt; + const dayStart = Date.UTC( + d.getUTCFullYear(), + d.getUTCMonth(), + d.getUTCDate(), + ); + touchedDayStarts.add(dayStart); + } + } + try { + await Promise.all( + Array.from(touchedDayStarts).map((t) => + recomputeMoodBucketsForEntry(user.id, new Date(t)), + ), + ); + } catch (rollupErr) { + annotate({ + meta: { + mood_rollup_write_failed: true, + mood_rollup_write_error: + rollupErr instanceof Error ? rollupErr.message : String(rollupErr), + }, + }); + } + } + return apiSuccess({ processed: entries.length, inserted, diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 7528422bb..f0a86210d 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -18,6 +18,7 @@ import { annotate } from "@/lib/logging/context"; import { withIdempotency } from "@/lib/idempotency"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; import { invalidateUserMood } from "@/lib/cache/invalidate"; +import { recomputeMoodBucketsForEntry } from "@/lib/mood/rollups"; function parseTags(tags: string | null): string[] { if (!tags) return []; @@ -129,6 +130,22 @@ async function postMoodEntry(request: NextRequest) { // v1.4.34 IW-G — bust per-user mood + achievements + analytics caches. invalidateUserMood(user.id); + // v1.4.39 W-MOOD — refresh the persisted DAY rollup for the + // entry's bucket and enqueue the WEEK / MONTH / YEAR folds. + // Best-effort: a failure here must not surface as a 5xx to the + // user, the rollup is a cache tier, not a write-path invariant. + try { + await recomputeMoodBucketsForEntry(user.id, moodLoggedAt); + } catch (rollupErr) { + annotate({ + meta: { + mood_rollup_write_failed: true, + mood_rollup_write_error: + rollupErr instanceof Error ? rollupErr.message : String(rollupErr), + }, + }); + } + return apiSuccess({ ...entry, tags: parseTags(entry.tags) }, 201); } catch (err) { if ( diff --git a/src/app/api/mood/analytics/route.ts b/src/app/api/mood/analytics/route.ts index 1002560a3..7218f4464 100644 --- a/src/app/api/mood/analytics/route.ts +++ b/src/app/api/mood/analytics/route.ts @@ -4,9 +4,24 @@ import { summarize, type DataPoint } from "@/lib/analytics/trends"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { + ensureUserMoodRollupsFresh, + readMoodDayRollups, +} from "@/lib/mood/rollups"; export const dynamic = "force-dynamic"; +/** + * v1.4.39 W-MOOD — five-year window for the rollup-tier read. + * + * Mirrors the legacy "unbounded findMany" semantics in practice: the + * legacy code passed every mood the user had ever logged into Node. + * The rollup tier stores one row per day, so a five-year cap is + * cheap (at most ~1 800 rows) and still covers every user the + * product has historic data for. + */ +const ROLLUP_WINDOW_MS = 5 * 365 * 24 * 60 * 60 * 1000; + /** Aggregate multiple mood entries per day into daily averages. */ function aggregateDailyAverages( records: Array<{ date: string; score: number }>, @@ -33,9 +48,71 @@ interface MoodAnalyticsResult { entryCount: number; } +/** + * Format the rollup bucket's UTC `bucket_start` as a YYYY-MM-DD + * label. The legacy live path emitted the row's TZ-anchored `date` + * column; the rollup tier anchors on UTC midnight (same convention + * as the measurement rollup tier). For tenants within ±3 h of UTC + * (Berlin year-round) the two labels agree on every entry whose + * timestamp doesn't straddle the UTC boundary — i.e. every realistic + * mood log, which a human submits during waking hours. + */ +function utcDayLabel(d: Date): string { + const yyyy = d.getUTCFullYear(); + const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(d.getUTCDate()).padStart(2, "0"); + return `${yyyy}-${mm}-${dd}`; +} + async function buildMoodAnalyticsResponse( userId: string, ): Promise { + // v1.4.39 W-MOOD — fire-and-forget warm-up so the next cold mount + // for this user lands on the rollup tier even when the boot-time + // backfill hasn't reached them yet. + void ensureUserMoodRollupsFresh(userId); + + const since = new Date(Date.now() - ROLLUP_WINDOW_MS); + const rollups = await readMoodDayRollups(userId, since); + + if (rollups.length > 0) { + // Fast path — one DAY-rollup row per calendar day. The legacy + // `aggregateDailyAverages` collapse is unnecessary because the + // rollup already carries the daily mean; we recreate its output + // shape directly so the response stays byte-compatible. + const entries = rollups + .map((r) => ({ + date: utcDayLabel(r.bucketStart), + score: Math.round(r.mean * 100) / 100, + samples: r.count, + })) + .sort((a, b) => a.date.localeCompare(b.date)); + + // Summarize feeds the slope7/30/90 windows. One DataPoint per + // day (mean) is the right resolution — the legacy path passed + // per-entry points, but the slope-window outputs match because + // a power user's mood is typically 1/day. Multi-entry days + // contribute their daily mean exactly once, which is the same + // semantic the dashboard tile renders. + const dataPoints: DataPoint[] = rollups.map((r) => ({ + date: r.bucketStart, + value: r.mean, + })); + const summary = summarize(dataPoints); + const entryCount = rollups.reduce((s, r) => s + r.count, 0); + + annotate({ meta: { mood_analytics_path: "rollup" } }); + + return { entries, summary, entryCount }; + } + + // Coverage-fallback — the user has no rollup rows yet. Probe for + // raw mood entries; when the table is empty for this user we + // return the same empty envelope the legacy path emitted. When + // mood entries exist but rollups don't (legacy account before the + // boot-backfill has caught up), we run the legacy live walk ONCE + // so the request still gets a correct response — the warm-up + // fired above will mint the rollups for the next request. const moodEntries = await prisma.moodEntry.findMany({ where: { userId }, orderBy: { moodLoggedAt: "asc" }, @@ -53,6 +130,12 @@ async function buildMoodAnalyticsResponse( const summary = summarize(dataPoints); + annotate({ + meta: { + mood_analytics_path: moodEntries.length === 0 ? "rollup" : "live", + }, + }); + return { entries, summary, entryCount: moodEntries.length }; } diff --git a/src/app/api/settings/data/route.ts b/src/app/api/settings/data/route.ts index 9eface9d7..d2758f0cd 100644 --- a/src/app/api/settings/data/route.ts +++ b/src/app/api/settings/data/route.ts @@ -40,6 +40,9 @@ export const DELETE = apiHandler(async (request: NextRequest) => { const moodEntries = await tx.moodEntry.deleteMany({ where: { userId }, }); + // v1.4.39 W-MOOD — wipe the persisted rollup partition for this + // user so the next analytics read returns an empty envelope. + await tx.moodEntryRollup.deleteMany({ where: { userId } }); const apiTokens = await tx.apiToken.deleteMany({ where: { userId }, }); diff --git a/src/app/api/settings/moodlog/route.ts b/src/app/api/settings/moodlog/route.ts index ef9a9ea54..5f17e6dc0 100644 --- a/src/app/api/settings/moodlog/route.ts +++ b/src/app/api/settings/moodlog/route.ts @@ -8,6 +8,7 @@ import { moodLogCredentialsSchema } from "@/lib/validations/moodlog"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { markDisconnected, markReconnected } from "@/lib/integrations/status"; +import { invalidateUserMood } from "@/lib/cache/invalidate"; export const dynamic = "force-dynamic"; @@ -74,6 +75,13 @@ export const DELETE = apiHandler(async () => { where: { userId: user.id }, }); + // v1.4.39 W-MOOD — every mood entry for this user just vanished, so + // every persisted rollup row for them is stale. Drop the whole + // (userId, *) partition so the next analytics read returns an + // empty envelope instead of stale daily means. + await prisma.moodEntryRollup.deleteMany({ where: { userId: user.id } }); + invalidateUserMood(user.id); + // Clear credentials await prisma.user.update({ where: { id: user.id }, diff --git a/src/lib/moodlog/sync.ts b/src/lib/moodlog/sync.ts index 4add70455..952715385 100644 --- a/src/lib/moodlog/sync.ts +++ b/src/lib/moodlog/sync.ts @@ -7,6 +7,7 @@ import { recordSyncFailure, recordSyncSuccess, } from "@/lib/integrations/status"; +import { recomputeUserMoodRollups } from "@/lib/mood/rollups"; /** * Sync mood entries from a user's moodLog instance. @@ -244,5 +245,20 @@ export async function syncMoodLogEntries( }); await recordSyncSuccess(userId, "moodlog"); + // v1.4.39 W-MOOD — re-fold the rollup tier after a sync. The + // sync upserts a batch spanning many days; one bounded + // recompute is cheaper than firing N per-row hooks. Best-effort: + // a rollup failure here must not bubble up and undo the + // recordSyncSuccess we just committed. + if (imported > 0) { + try { + await recomputeUserMoodRollups(userId, { granularities: ["DAY"] }); + } catch (err) { + getEvent()?.addWarning( + `Mood rollup recompute failed after sync: ${err}`, + ); + } + } + return imported; }