diff --git a/src/app/api/bugreport/status/route.ts b/src/app/api/bugreport/status/route.ts index a1f5aa88c..d7387fb4c 100644 --- a/src/app/api/bugreport/status/route.ts +++ b/src/app/api/bugreport/status/route.ts @@ -12,17 +12,16 @@ import { apiSuccess, apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; +import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; -export const GET = apiHandler(async () => { - const { user } = await requireAuth(); - - // Light rate limit — the endpoint is cheap, but no reason to let a logged-in - // client hammer Postgres in a loop. - const rl = await checkRateLimit(`bugreport-status:${user.id}`, 30, 60 * 1000); - if (!rl.allowed) { - return apiError("Rate limit exceeded", 429); - } +interface BugreportStatusCacheValue { + configured: boolean; + enabled: boolean; + hasToken: boolean; + hasRepo: boolean; +} +async function buildBugreportStatusValue(): Promise { const appSettings = await prisma.appSettings.findUnique({ where: { id: "singleton" }, select: { @@ -42,10 +41,44 @@ export const GET = apiHandler(async () => { // Default ON when the column has never been written. const enabled = appSettings?.bugReportEnabled !== false; + return { configured, enabled, hasToken, hasRepo }; +} + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + // Light rate limit — the endpoint is cheap, but no reason to let a logged-in + // client hammer Postgres in a loop. + const rl = await checkRateLimit(`bugreport-status:${user.id}`, 30, 60 * 1000); + if (!rl.allowed) { + return apiError("Rate limit exceeded", 429); + } + + // Cache the global app-settings shape on a singleton key (per blueprint §3). + // `isAdmin` lives outside the cache because it varies per request. The + // `invalidateAppSettings()` helper called from + // `PATCH /api/admin/app-settings` evicts this entry when bug-report + // configuration changes. + const status = await cached( + caches.bugreportStatus as ServerCache, + "singleton", + () => buildBugreportStatusValue(), + annotate, + ); + annotate({ action: { name: "bugreport.status" }, - meta: { configured, enabled, has_token: hasToken, has_repo: hasRepo }, + meta: { + configured: status.configured, + enabled: status.enabled, + has_token: status.hasToken, + has_repo: status.hasRepo, + }, }); - return apiSuccess({ configured, enabled, isAdmin: user.role === "ADMIN" }); + return apiSuccess({ + configured: status.configured, + enabled: status.enabled, + isAdmin: user.role === "ADMIN", + }); }); diff --git a/src/app/api/dashboard/chart-overlay-prefs/route.ts b/src/app/api/dashboard/chart-overlay-prefs/route.ts index 36b15a483..c3b842ea0 100644 --- a/src/app/api/dashboard/chart-overlay-prefs/route.ts +++ b/src/app/api/dashboard/chart-overlay-prefs/route.ts @@ -25,6 +25,7 @@ import { serializeDashboardLayout, type DashboardLayout, } from "@/lib/dashboard-layout"; +import { invalidateUserDashboardWidgets } from "@/lib/cache/invalidate"; import { Prisma } from "@/generated/prisma/client"; import { z } from "zod/v4"; import type { NextRequest } from "next/server"; @@ -86,6 +87,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { { isolationLevel: "Serializable" }, ); + // The partial-update path mutates the same `User.dashboardWidgetsJson` + // blob the `/api/dashboard/widgets` GET reads from. Bust the cache so + // the next dashboard mount surfaces the new chartOverlayPrefs slot. + invalidateUserDashboardWidgets(user.id); + annotate({ action: { name: "dashboard.chartOverlayPrefs.update" }, meta: { diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 11b90ce81..89de13a32 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -22,6 +22,7 @@ import { import { Prisma } from "@/generated/prisma/client"; import { z } from "zod/v4"; import { invalidateUserDashboardWidgets } from "@/lib/cache/invalidate"; +import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; import type { NextRequest } from "next/server"; // Single source of truth — every widget id rendered by the Settings → @@ -94,15 +95,26 @@ const layoutSchema = z.object({ .optional(), }); +async function buildDashboardLayout(userId: string): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { dashboardWidgetsJson: true }, + }); + return resolveDashboardLayout(row?.dashboardWidgetsJson); +} + export const GET = apiHandler(async () => { const { user } = await requireAuth(); - const row = await prisma.user.findUnique({ - where: { id: user.id }, - select: { dashboardWidgetsJson: true }, - }); - - const layout = resolveDashboardLayout(row?.dashboardWidgetsJson); + // 5-minute TTL per blueprint §5; the layout changes only when the user + // hits the Settings → Dashboard save button, which invalidates this + // cache via `invalidateUserDashboardWidgets()`. + const layout = await cached( + caches.dashboardWidgets as ServerCache, + user.id, + () => buildDashboardLayout(user.id), + annotate, + ); return apiSuccess(layout); }); diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index f002a412b..eb18ad5bf 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -16,40 +16,34 @@ import { import { serializeScheduleRecurrence } from "@/lib/medication-schedule"; import { getUserTodayBounds } from "@/lib/timezone"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; import { NextRequest } from "next/server"; -export const GET = apiHandler(async () => { - const { user } = await requireAuth(); +type MedicationsListResult = Array>; - // Compute today's UTC range for event counting - const userTz = user.timezone || "Europe/Berlin"; +async function buildMedicationsList( + userId: string, + userTz: string, +): Promise { const { start: todayStartUtc, end: todayEndUtc } = getUserTodayBounds( new Date(), userTz, ); - // Run all three queries in parallel const [medications, latestIntakes, todayEvents] = await Promise.all([ prisma.medication.findMany({ - where: { userId: user.id }, + where: { userId }, include: { schedules: true }, orderBy: { createdAt: "desc" }, }), prisma.medicationIntakeEvent.groupBy({ by: ["medicationId"], - where: { - userId: user.id, - skipped: false, - takenAt: { not: null }, - }, + where: { userId, skipped: false, takenAt: { not: null } }, _max: { takenAt: true }, }), prisma.medicationIntakeEvent.groupBy({ by: ["medicationId"], - where: { - userId: user.id, - scheduledFor: { gte: todayStartUtc, lte: todayEndUtc }, - }, + where: { userId, scheduledFor: { gte: todayStartUtc, lte: todayEndUtc } }, _count: { id: true }, }), ]); @@ -76,24 +70,36 @@ export const GET = apiHandler(async () => { getEvent()?.addWarning("Medication categories could not be loaded"); } + return medications.map((m) => ({ + ...m, + category: categoryMap[m.id] ?? "OTHER", + lastTakenAt: lastTakenAtByMedicationId[m.id] ?? null, + todayEventCount: todayEventCountByMedId[m.id] ?? 0, + })); +} + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const userTz = user.timezone || "Europe/Berlin"; + + // Cache the list shape on userId. The 60s TTL bounds the cross-midnight + // staleness window for `todayEventCount` (the only time-of-day-derived + // field in the response); writes via POST/PUT/DELETE flush through + // `invalidateUserMedications` before the next read lands. + const result = await cached( + caches.medications as ServerCache, + user.id, + () => buildMedicationsList(user.id, userTz), + annotate, + ); + annotate({ action: { name: "medication.list" }, - meta: { count: medications.length }, + meta: { count: result.length }, }); - return apiSuccess( - medications.map((m) => ({ - ...m, - // v1.4.25 W4d — `category` is the existing clinical taxonomy - // (BLOOD_PRESSURE / VITAMIN / ...) from the side-table; the - // Prisma model has its own `treatmentClass` (GENERIC | GLP1) that - // spreads through `...m` and unlocks the GLP-1 surfaces. Two - // orthogonal concepts, both exposed. - category: categoryMap[m.id] ?? "OTHER", - lastTakenAt: lastTakenAtByMedicationId[m.id] ?? null, - todayEventCount: todayEventCountByMedId[m.id] ?? 0, - })), - ); + return apiSuccess(result); }); export const POST = apiHandler(async (request: NextRequest) => { diff --git a/src/app/api/mood/analytics/route.ts b/src/app/api/mood/analytics/route.ts index 6be9db8f2..1002560a3 100644 --- a/src/app/api/mood/analytics/route.ts +++ b/src/app/api/mood/analytics/route.ts @@ -3,6 +3,7 @@ import { apiSuccess } from "@/lib/api-response"; 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"; export const dynamic = "force-dynamic"; @@ -26,11 +27,17 @@ function aggregateDailyAverages( })); } -export const GET = apiHandler(async () => { - const { user } = await requireAuth(); +interface MoodAnalyticsResult { + entries: Array<{ date: string; score: number; samples: number }>; + summary: ReturnType; + entryCount: number; +} +async function buildMoodAnalyticsResponse( + userId: string, +): Promise { const moodEntries = await prisma.moodEntry.findMany({ - where: { userId: user.id }, + where: { userId }, orderBy: { moodLoggedAt: "asc" }, select: { date: true, score: true, moodLoggedAt: true }, }); @@ -39,7 +46,6 @@ export const GET = apiHandler(async () => { moodEntries.map((e) => ({ date: e.date, score: e.score })), ); - // Build DataPoint array for summarize() using moodLoggedAt as the date const dataPoints: DataPoint[] = moodEntries.map((e) => ({ date: e.moodLoggedAt, value: e.score, @@ -47,10 +53,23 @@ export const GET = apiHandler(async () => { const summary = summarize(dataPoints); + return { entries, summary, entryCount: moodEntries.length }; +} + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const result = await cached( + caches.moodAnalytics as ServerCache, + user.id, + () => buildMoodAnalyticsResponse(user.id), + annotate, + ); + annotate({ action: { name: "mood.analytics" }, - meta: { entryCount: moodEntries.length }, + meta: { entryCount: result.entryCount }, }); - return apiSuccess({ entries, summary }); + return apiSuccess({ entries: result.entries, summary: result.summary }); }); diff --git a/src/app/api/workouts/__tests__/canonical-dedup.test.ts b/src/app/api/workouts/__tests__/canonical-dedup.test.ts index b220c5f02..c24ad2064 100644 --- a/src/app/api/workouts/__tests__/canonical-dedup.test.ts +++ b/src/app/api/workouts/__tests__/canonical-dedup.test.ts @@ -16,6 +16,8 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; + vi.mock("@/lib/db", () => ({ prisma: { workout: { @@ -100,6 +102,11 @@ function makeRow( describe("GET /api/workouts — canonical dedup", () => { beforeEach(() => { vi.clearAllMocks(); + // Reset every server cache between tests so a fixed userId doesn't + // carry cached state across cases (v1.4.34.2 — added when the + // `/api/workouts` GET handler started reading through + // `caches.workouts`). + __resetAllCachesForTests(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); // Default: no per-user source-priority override → fall back to the // canonical APPLE_HEALTH ≻ WITHINGS ≻ MANUAL ≻ IMPORT ladder. diff --git a/src/app/api/workouts/route.ts b/src/app/api/workouts/route.ts index b3a965b2c..4fe739e6a 100644 --- a/src/app/api/workouts/route.ts +++ b/src/app/api/workouts/route.ts @@ -54,10 +54,119 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; const DEFAULT_LIMIT = 50; const MAX_LIMIT = 200; +interface WorkoutsParams { + readonly limit: number; + readonly offset: number; + readonly since: string | null; + readonly until: string | null; + readonly sportType: string | null; +} + +interface WorkoutsResult { + workouts: Array<{ + id: string; + sportType: string | null; + startedAt: Date | null; + endedAt: Date | null; + durationSec: number | null; + distanceM: number | null; + activeEnergyKcal: number | null; + avgHr: number | null; + maxHr: number | null; + source: string; + externalId: string | null; + }>; + meta: { + total: number; + limit: number; + offset: number; + droppedDuplicates: number; + }; +} + +async function buildWorkoutsResponse( + userId: string, + params: WorkoutsParams, +): Promise { + const { limit, offset, since, until, sportType } = params; + + const where: Record = { userId }; + if (since || until) { + const range: Record = {}; + if (since) { + const d = new Date(since); + if (!Number.isNaN(d.getTime())) range.gte = d; + } + if (until) { + const d = new Date(until); + if (!Number.isNaN(d.getTime())) range.lte = d; + } + if (Object.keys(range).length > 0) { + where.startedAt = range; + } + } + if (sportType) { + where.sportType = sportType; + } + + const rows = await prisma.workout.findMany({ + where, + orderBy: [{ startedAt: "desc" }, { id: "asc" }], + select: { + id: true, + source: true, + externalId: true, + sportType: true, + startedAt: true, + endedAt: true, + durationSec: true, + totalDistanceM: true, + totalEnergyKcal: true, + avgHeartRate: true, + maxHeartRate: true, + createdAt: true, + }, + }); + + const userRow = await prisma.user.findUnique({ + where: { id: userId }, + select: { sourcePriorityJson: true }, + }); + const canonical = pickCanonicalWorkoutRows( + rows, + userRow?.sourcePriorityJson ?? null, + ); + + const page = canonical.slice(offset, offset + limit).map((row) => ({ + id: row.id, + sportType: row.sportType, + startedAt: row.startedAt, + endedAt: row.endedAt, + durationSec: row.durationSec, + distanceM: row.totalDistanceM, + activeEnergyKcal: row.totalEnergyKcal, + avgHr: row.avgHeartRate, + maxHr: row.maxHeartRate, + source: row.source, + externalId: row.externalId, + })); + + return { + workouts: page, + meta: { + total: canonical.length, + limit, + offset, + droppedDuplicates: rows.length - canonical.length, + }, + }; +} + export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "workouts.list" } }); @@ -79,80 +188,15 @@ export const GET = apiHandler(async (request: NextRequest) => { const until = searchParams.get("until"); const sportType = searchParams.get("sportType"); - const where: Record = { userId: user.id }; - if (since || until) { - const range: Record = {}; - if (since) { - const d = new Date(since); - if (!Number.isNaN(d.getTime())) range.gte = d; - } - if (until) { - const d = new Date(until); - if (!Number.isNaN(d.getTime())) range.lte = d; - } - if (Object.keys(range).length > 0) { - where.startedAt = range; - } - } - if (sportType) { - where.sportType = sportType; - } + const params: WorkoutsParams = { limit, offset, since, until, sportType }; + const cacheKey = `${user.id}|${limit}|${offset}|${since ?? ""}|${until ?? ""}|${sportType ?? ""}`; - // Read the full filtered set, descending start. The picker requires - // deterministic input order; the `(startedAt, id)` secondary sort - // pins the tie-break for rows that share a millisecond stamp. - const rows = await prisma.workout.findMany({ - where, - orderBy: [{ startedAt: "desc" }, { id: "asc" }], - select: { - id: true, - source: true, - externalId: true, - sportType: true, - startedAt: true, - endedAt: true, - durationSec: true, - totalDistanceM: true, - totalEnergyKcal: true, - avgHeartRate: true, - maxHeartRate: true, - createdAt: true, - }, - }); - - // Pass the user's source-priority blob so the picker honours the - // per-user ladder. The picker preserves input order on the returned - // subset; descending-start input → descending-start output. - const userRow = await prisma.user.findUnique({ - where: { id: user.id }, - select: { sourcePriorityJson: true }, - }); - const canonical = pickCanonicalWorkoutRows( - rows, - userRow?.sourcePriorityJson ?? null, + const result = await cached( + caches.workouts as ServerCache, + cacheKey, + () => buildWorkoutsResponse(user.id, params), + annotate, ); - const page = canonical.slice(offset, offset + limit).map((row) => ({ - id: row.id, - sportType: row.sportType, - startedAt: row.startedAt, - endedAt: row.endedAt, - durationSec: row.durationSec, - distanceM: row.totalDistanceM, - activeEnergyKcal: row.totalEnergyKcal, - avgHr: row.avgHeartRate, - maxHr: row.maxHeartRate, - source: row.source, - externalId: row.externalId, - })); - - return apiSuccess({ - workouts: page, - meta: { - total: canonical.length, - limit, - offset, - droppedDuplicates: rows.length - canonical.length, - }, - }); + return apiSuccess(result); });