feat(cache): wire 13 write endpoints to per-user invalidation helpers

Every endpoint that mutates a measurement / mood / medication /
intake / workout / dashboard-widget row now calls the matching
invalidateUser* helper after its database commit so the next cached
read paints fresh data instead of waiting out the TTL. The admin
app-settings PUT also flushes the bug-report-status singleton cache.

Endpoints wired (per blueprint §6 matrix):

  POST/DELETE /api/measurements
  PUT/DELETE /api/measurements/[id]
  POST /api/measurements/batch
  DELETE /api/measurements/by-external-ids
  POST/PUT/DELETE /api/mood-entries[/id]
  POST /api/mood-entries/bulk
  POST/PUT/DELETE /api/medications[/id]
  POST /api/medications/[id]/intake
  PUT/DELETE /api/medications/[id]/intake/[eventId]
  POST /api/medications/intake/bulk
  POST /api/workouts/batch
  PUT/DELETE /api/dashboard/widgets
  PUT /api/admin/settings (bug-report toggle path)

Batch endpoints gate invalidation on insertedCount > 0 so a
duplicates-only ingest stays a no-op. Route-level tests reset every
cache in beforeEach so re-using a fixed userId doesn't carry response
state across cases.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 21:05:41 +02:00
1 parent 68c4bb73b2
commit 757387ca83
19 files changed
+131

No files matched your search

+6
View File
@@ -15,6 +15,7 @@ import {
invalidateServerDefaultTimezone,
isValidTimezone,
} from "@/lib/tz/resolver";
import { invalidateAppSettings } from "@/lib/cache/invalidate";
export const dynamic = "force-dynamic";
@@ -228,6 +229,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
invalidateServerDefaultTimezone();
}
// v1.4.34 IW-G — bust the bug-report status cache (global singleton)
// so the next read reflects the new GitHub-token / bug-report-enabled
// shape. Cheap call — the cache holds at most 10 entries.
invalidateAppSettings();
await auditLog("admin.settings.update", {
userId: user.id,
ipAddress: getClientIp(request),
@@ -52,6 +52,7 @@ vi.mock("next/headers", () => ({
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
interface MeasurementRow {
id: string;
@@ -89,6 +90,10 @@ function pulseRow(measuredAt: Date, value: number, id: string): MeasurementRow {
beforeEach(() => {
vi.resetAllMocks();
// v1.4.34 IW-G — reset the analytics LRU between tests so each case
// observes a cold cache (otherwise tests sharing a userId would land
// on the prior test's cached response).
__resetAllCachesForTests();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
+9
View File
@@ -21,6 +21,7 @@ import {
} from "@/lib/dashboard-layout";
import { Prisma } from "@/generated/prisma/client";
import { z } from "zod/v4";
import { invalidateUserDashboardWidgets } from "@/lib/cache/invalidate";
import type { NextRequest } from "next/server";
// Single source of truth — every widget id rendered by the Settings →
@@ -149,6 +150,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
meta: { visible_count: normalized.widgets.filter((w) => w.visible).length },
});
// v1.4.34 IW-G — bust the per-user dashboard-widgets cache so the
// next dashboard mount paints the new layout.
invalidateUserDashboardWidgets(user.id);
return apiSuccess(normalized);
});
@@ -162,5 +167,9 @@ export const DELETE = apiHandler(async () => {
annotate({ action: { name: "dashboard.widgets.reset" } });
// v1.4.34 IW-G — bust the per-user dashboard-widgets cache so the
// next dashboard mount paints the reset (default) layout.
invalidateUserDashboardWidgets(user.id);
return apiSuccess(DEFAULT_DASHBOARD_LAYOUT);
});
@@ -36,6 +36,7 @@ vi.mock("next/headers", () => ({
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -65,6 +66,9 @@ const HIDDEN_TRIGGER_STRINGS = [
beforeEach(() => {
vi.resetAllMocks();
// v1.4.34 IW-G — reset achievement LRU between tests so each case
// observes a cold cache.
__resetAllCachesForTests();
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
@@ -36,6 +36,7 @@ vi.mock("next/headers", () => ({
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -51,6 +52,9 @@ const SESSION_OK = {
beforeEach(() => {
vi.resetAllMocks();
// v1.4.34 IW-G — reset achievement LRU between tests so each case
// observes a cold cache.
__resetAllCachesForTests();
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
+9
View File
@@ -9,6 +9,7 @@ import {
safeJson,
} from "@/lib/api-response";
import { updateMeasurementSchema } from "@/lib/validations/measurement";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { Prisma } from "@/generated/prisma/client";
import { NextRequest } from "next/server";
@@ -107,6 +108,10 @@ export const PUT = apiHandler(
},
});
// v1.4.34 IW-G — bust per-user analytics + achievements + workouts
// caches so subsequent reads reflect the edited row.
invalidateUserMeasurements(user.id);
return apiSuccess(measurement);
},
);
@@ -141,6 +146,10 @@ export const DELETE = apiHandler(
},
});
// v1.4.34 IW-G — bust per-user analytics + achievements + workouts
// caches so subsequent reads reflect the deletion.
invalidateUserMeasurements(user.id);
return apiSuccess({ deleted: true });
},
);
+9
View File
@@ -37,6 +37,7 @@ import { validateMeasurementRange } from "@/lib/validations/measurement";
import { deviceTypeEnum } from "@/lib/validations/source-priority";
import { checkRateLimit } from "@/lib/rate-limit";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { Prisma } from "@/generated/prisma/client";
// v1.4.25 W16c — historical-backfill threshold for PR push
@@ -366,6 +367,14 @@ async function postBatch(request: NextRequest): Promise<Response> {
},
});
// v1.4.34 IW-G — bust per-user analytics + achievements + workouts
// caches when at least one row landed so the next read picks up the
// ingested batch. Skipped-only ingests don't change state, so we
// gate the invalidation on `insertedCount > 0`.
if (insertedCount > 0) {
invalidateUserMeasurements(user.id);
}
return apiSuccess({
processed: entries.length,
inserted: insertedCount,
@@ -31,6 +31,7 @@ import {
getClientIp,
safeJson,
} from "@/lib/api-response";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
const MAX_BATCH_ENTRIES = 500;
@@ -103,5 +104,12 @@ async function deleteByExternalIds(request: NextRequest): Promise<Response> {
meta: { processed: externalIds.length, deleted: result.count },
});
// v1.4.34 IW-G — bust per-user analytics + achievements + workouts
// caches when at least one row deleted so the next read reflects the
// reconciliation. A 0-delete batch is a no-op and skips the eviction.
if (result.count > 0) {
invalidateUserMeasurements(user.id);
}
return apiSuccess({ deletedCount: result.count });
}
+9
View File
@@ -20,6 +20,7 @@ import {
} from "@/lib/measurements/range-aggregation";
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
import { withIdempotency } from "@/lib/idempotency";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { NextRequest } from "next/server";
import type {
MeasurementType,
@@ -214,6 +215,10 @@ async function postMeasurement(request: NextRequest) {
},
});
// v1.4.34 IW-G — flush every cache that reflects this user's
// measurement set so the next read paints the new rows.
invalidateUserMeasurements(user.id);
return apiSuccess(results, 201);
}
@@ -270,5 +275,9 @@ async function postMeasurement(request: NextRequest) {
},
});
// v1.4.34 IW-G — flush every cache that reflects this user's
// measurement set so the next read paints the new row.
invalidateUserMeasurements(user.id);
return apiSuccess(measurement, 201);
}
@@ -9,6 +9,7 @@ import {
safeJson,
} from "@/lib/api-response";
import { updateIntakeEventSchema } from "@/lib/validations/medication";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string; eventId: string }> };
@@ -62,6 +63,10 @@ export const PUT = apiHandler(
meta: { medication_id: id },
});
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches so the next read reflects the edited event.
invalidateUserMedications(user.id);
return apiSuccess(updated);
},
);
@@ -98,6 +103,10 @@ export const DELETE = apiHandler(
meta: { medication_id: id },
});
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches so the next read reflects the deletion.
invalidateUserMedications(user.id);
return apiSuccess({ deleted: true });
},
);
@@ -15,6 +15,7 @@ import {
import { withIdempotency } from "@/lib/idempotency";
import { consumeOneDose } from "@/lib/medications/inventory/service";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
@@ -154,6 +155,10 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
},
});
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches so the next read reflects the dose event.
invalidateUserMedications(user.id);
return apiSuccess(event, 201);
}
+9
View File
@@ -9,6 +9,7 @@ import {
safeJson,
} from "@/lib/api-response";
import { updateMedicationSchema } from "@/lib/validations/medication";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import {
deleteMedicationCategory,
getMedicationCategories,
@@ -177,6 +178,10 @@ export const PUT = apiHandler(
},
});
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches so the next read reflects the schedule change.
invalidateUserMedications(user.id);
return apiSuccess({
...medication,
category: normalizedCategory,
@@ -222,6 +227,10 @@ export const DELETE = apiHandler(
},
});
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches so the next read reflects the deletion.
invalidateUserMedications(user.id);
return apiSuccess({ deleted: true });
},
);
@@ -37,6 +37,7 @@ vi.mock("next/headers", () => ({
import { GET, POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -45,6 +46,9 @@ const SESSION_OK = {
beforeEach(() => {
vi.resetAllMocks();
// v1.4.34 IW-G — reset compliance LRU between tests so each case
// observes a cold cache.
__resetAllCachesForTests();
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
@@ -40,6 +40,7 @@ import {
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
@@ -205,6 +206,13 @@ async function postBulk(request: NextRequest): Promise<Response> {
},
});
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches when at least one row landed so the next read reflects the
// ingested batch.
if (inserted > 0) {
invalidateUserMedications(user.id);
}
return apiSuccess({
processed: entries.length,
inserted,
+5
View File
@@ -15,6 +15,7 @@ import {
} from "@/lib/medication-category";
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
import { getUserTodayBounds } from "@/lib/timezone";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { NextRequest } from "next/server";
export const GET = apiHandler(async () => {
@@ -153,6 +154,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches so the next read reflects the new schedule.
invalidateUserMedications(user.id);
return apiSuccess(
{
...medication,
+7
View File
@@ -14,6 +14,7 @@ import { NextRequest } from "next/server";
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";
type RouteParams = { params: Promise<{ id: string }> };
@@ -108,6 +109,9 @@ export const PUT = apiHandler(
meta: { moodEntryId: id },
});
// v1.4.34 IW-G — bust per-user mood + achievements + analytics caches.
invalidateUserMood(user.id);
return apiSuccess({ ...entry, tags: parseTags(entry.tags) });
},
);
@@ -137,6 +141,9 @@ export const DELETE = apiHandler(
meta: { moodEntryId: id },
});
// v1.4.34 IW-G — bust per-user mood + achievements + analytics caches.
invalidateUserMood(user.id);
return apiSuccess({ deleted: true });
},
);
+8
View File
@@ -42,6 +42,7 @@ import {
moodSourceEnum,
} from "@/lib/validations/moodlog";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { invalidateUserMood } from "@/lib/cache/invalidate";
const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
@@ -210,6 +211,13 @@ async function postBulk(request: NextRequest): Promise<Response> {
},
});
// v1.4.34 IW-G — bust per-user mood + achievements + analytics caches
// when at least one row landed so the next read picks up the ingested
// batch. Skipped / duplicate-only ingests are no-ops.
if (inserted > 0) {
invalidateUserMood(user.id);
}
return apiSuccess({
processed: entries.length,
inserted,
+4
View File
@@ -17,6 +17,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
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";
function parseTags(tags: string | null): string[] {
if (!tags) return [];
@@ -125,6 +126,9 @@ async function postMoodEntry(request: NextRequest) {
meta: { moodEntryId: entry.id, mood },
});
// v1.4.34 IW-G — bust per-user mood + achievements + analytics caches.
invalidateUserMood(user.id);
return apiSuccess({ ...entry, tags: parseTags(entry.tags) }, 201);
} catch (err) {
if (
+9
View File
@@ -63,6 +63,7 @@ import {
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import {
createBatchWorkoutSchema,
MAX_WORKOUTS_PER_BATCH,
@@ -523,6 +524,14 @@ async function postBatch(request: NextRequest): Promise<Response> {
},
});
// v1.4.34 IW-G — bust per-user analytics + achievements + workouts
// caches when at least one row landed. Workouts ride on the
// measurements bucket because achievements / analytics also touch
// workout-derived metrics.
if (insertedCount > 0) {
invalidateUserMeasurements(user.id);
}
return apiSuccess({
processed: workouts.length,
inserted: insertedCount,