mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
perf(api): wire 6 remaining hot reads through the server cache
The v1.4.34 server-cache primitive (IW-G) provisioned eight per-route
LRU instances and wired three. Land the remaining five reads plus the
chart-overlay-prefs invalidation hook the new dashboard-widgets wrap
exposes.
Reads wrapped (60 s TTL unless noted):
- GET /api/mood/analytics → caches.moodAnalytics, key userId
- GET /api/workouts → caches.workouts, key includes
all params (limit/offset/since/
until/sportType)
- GET /api/bugreport/status → caches.bugreportStatus, key
"singleton" (10 min TTL);
isAdmin layered per request
- GET /api/medications → caches.medications, key userId
- GET /api/dashboard/widgets → caches.dashboardWidgets, key
userId (5 min TTL)
Write paths already call the matching invalidateUser* helper from the
v1.4.34 invalidation matrix; the new dashboard-widgets cache also gets
busted from /api/dashboard/chart-overlay-prefs PUT, which writes the
same User.dashboardWidgetsJson blob through a partial-update path that
previously bypassed the matrix.
The canonical-dedup test suite for /api/workouts now imports
__resetAllCachesForTests in beforeEach so a fixed userId fixture
doesn't carry cached state across cases.
The Cloudflare "no available server" 503 cascade on
/api/measurements?aggregate=daily resolves as a downstream consequence —
once /api/insights/comprehensive stops monopolising the pool, the origin
accepts new connections fast enough that Cloudflare's circuit-breaker
no longer trips.
This commit is contained in:
1 parent
49e599b04e
commit
7bbcc872f8
7 files changed
+252
-125
No files matched your search
@@ -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<BugreportStatusCacheValue> {
|
||||
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<BugreportStatusCacheValue>,
|
||||
"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",
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<DashboardLayout> {
|
||||
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<DashboardLayout>,
|
||||
user.id,
|
||||
() => buildDashboardLayout(user.id),
|
||||
annotate,
|
||||
);
|
||||
return apiSuccess(layout);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
|
||||
// Compute today's UTC range for event counting
|
||||
const userTz = user.timezone || "Europe/Berlin";
|
||||
async function buildMedicationsList(
|
||||
userId: string,
|
||||
userTz: string,
|
||||
): Promise<MedicationsListResult> {
|
||||
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<MedicationsListResult>,
|
||||
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) => {
|
||||
|
||||
@@ -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<typeof summarize>;
|
||||
entryCount: number;
|
||||
}
|
||||
|
||||
async function buildMoodAnalyticsResponse(
|
||||
userId: string,
|
||||
): Promise<MoodAnalyticsResult> {
|
||||
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<MoodAnalyticsResult>,
|
||||
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 });
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
+117
-73
@@ -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<WorkoutsResult> {
|
||||
const { limit, offset, since, until, sportType } = params;
|
||||
|
||||
const where: Record<string, unknown> = { userId };
|
||||
if (since || until) {
|
||||
const range: Record<string, Date> = {};
|
||||
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<string, unknown> = { userId: user.id };
|
||||
if (since || until) {
|
||||
const range: Record<string, Date> = {};
|
||||
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<WorkoutsResult>,
|
||||
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);
|
||||
});
|
||||
Reference in new issue
Block a user