mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
68c4bb73b2
commit
757387ca83
19 files changed
+131
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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,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 });
|
||||
},
|
||||
);
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user