From 757387ca8386d91a81e1ea6376076cc2c21cead8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 21:05:41 +0200 Subject: [PATCH] feat(cache): wire 13 write endpoints to per-user invalidation helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/api/admin/settings/route.ts | 6 ++++++ src/app/api/analytics/__tests__/route.test.ts | 5 +++++ src/app/api/dashboard/widgets/route.ts | 9 +++++++++ .../achievements/__tests__/hidden-redaction.test.ts | 4 ++++ .../achievements/__tests__/ios-format.test.ts | 4 ++++ src/app/api/measurements/[id]/route.ts | 9 +++++++++ src/app/api/measurements/batch/route.ts | 9 +++++++++ src/app/api/measurements/by-external-ids/route.ts | 8 ++++++++ src/app/api/measurements/route.ts | 9 +++++++++ src/app/api/medications/[id]/intake/[eventId]/route.ts | 9 +++++++++ src/app/api/medications/[id]/intake/route.ts | 5 +++++ src/app/api/medications/[id]/route.ts | 9 +++++++++ src/app/api/medications/intake/__tests__/route.test.ts | 4 ++++ src/app/api/medications/intake/bulk/route.ts | 8 ++++++++ src/app/api/medications/route.ts | 5 +++++ src/app/api/mood-entries/[id]/route.ts | 7 +++++++ src/app/api/mood-entries/bulk/route.ts | 8 ++++++++ src/app/api/mood-entries/route.ts | 4 ++++ src/app/api/workouts/batch/route.ts | 9 +++++++++ 19 files changed, 131 insertions(+) diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index ac921609b..0e3e580f3 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -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), diff --git a/src/app/api/analytics/__tests__/route.test.ts b/src/app/api/analytics/__tests__/route.test.ts index 65f80256d..88e304d4e 100644 --- a/src/app/api/analytics/__tests__/route.test.ts +++ b/src/app/api/analytics/__tests__/route.test.ts @@ -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( diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 710893ec1..11b90ce81 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -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); }); diff --git a/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts b/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts index 2b9dcbfe1..6e810661a 100644 --- a/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts +++ b/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts @@ -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, diff --git a/src/app/api/gamification/achievements/__tests__/ios-format.test.ts b/src/app/api/gamification/achievements/__tests__/ios-format.test.ts index e9c3782cb..1b19d5476 100644 --- a/src/app/api/gamification/achievements/__tests__/ios-format.test.ts +++ b/src/app/api/gamification/achievements/__tests__/ios-format.test.ts @@ -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, diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index ff498c5ec..5fd024069 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -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 }); }, ); diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index a5587cf25..052e6620e 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -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 { }, }); + // 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, diff --git a/src/app/api/measurements/by-external-ids/route.ts b/src/app/api/measurements/by-external-ids/route.ts index eb4d3d673..373272ca9 100644 --- a/src/app/api/measurements/by-external-ids/route.ts +++ b/src/app/api/measurements/by-external-ids/route.ts @@ -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 { 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 }); } diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index ee203e7e1..82c1cd3c4 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -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); } diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index 57c3fbf5f..0214d67cf 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -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 }); }, ); diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 06aab2174..f6172177c 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -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); } diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 48c28a592..970e7b96c 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -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 }); }, ); diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts index d01908e1b..c16e0e729 100644 --- a/src/app/api/medications/intake/__tests__/route.test.ts +++ b/src/app/api/medications/intake/__tests__/route.test.ts @@ -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, ); diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 6f24ce095..fb7aa846b 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -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 { }, }); + // 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, diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 561a453d5..f002a412b 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -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, diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index 23ffc98d0..3aa4994ee 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -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 }); }, ); diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index 24fc2abca..54555b71a 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -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 { }, }); + // 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, diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index d56d310ce..7528422bb 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -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 ( diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts index ec5a2264b..253692a44 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -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 { }, }); + // 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,