From 1d559d7ed01b6901dbc0195af6b5cd3a3e642aef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 06:45:35 +0200 Subject: [PATCH] fix(nutrients): evict the dashboard snapshot on a landed batch row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/nutrients/batch performed zero cache invalidation, so an Apple-Health-synced water/nutrient total kept the dashboard's tile serving the pre-sync figure until the analytics fresh TTL lapsed (~3 min) — the manual water POST already hard-evicts on its own write. Calls invalidateUserDashboardSnapshot(user.id) once at least one row inserted or updated, mirroring the manual route's own posture so a synced total reflects on the very next dashboard read. --- .../nutrients/batch/__tests__/route.test.ts | 38 +++++++++++++++++++ src/app/api/nutrients/batch/route.ts | 8 ++++ 2 files changed, 46 insertions(+) diff --git a/src/app/api/nutrients/batch/__tests__/route.test.ts b/src/app/api/nutrients/batch/__tests__/route.test.ts index 1faac5028..b985d25c7 100644 --- a/src/app/api/nutrients/batch/__tests__/route.test.ts +++ b/src/app/api/nutrients/batch/__tests__/route.test.ts @@ -46,6 +46,9 @@ vi.mock("@/lib/idempotency", () => ({ fn(...args), })); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserDashboardSnapshot: vi.fn(), +})); vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); @@ -65,6 +68,7 @@ import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { auditLog } from "@/lib/auth/audit"; +import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -315,6 +319,40 @@ describe("POST /api/nutrients/batch — write semantics", () => { expect(prisma.nutrientIntakeDay.update).not.toHaveBeenCalled(); }); + // v1.30.3 (QA F9) — a landed row must evict the dashboard snapshot so + // the water/nutrient tile reflects the new total on the very next read, + // matching the manual water POST's own posture (`nutrients/water/route.ts`). + it("evicts the dashboard snapshot when a row lands", async () => { + const day = recentDay(); + const res = await POST( + postReq({ + entries: [{ day, nutrient: "vitamin_d", unit: "ug", amount: 22.5 }], + }), + ); + expect(res.status).toBe(200); + expect(invalidateUserDashboardSnapshot).toHaveBeenCalledTimes(1); + expect(invalidateUserDashboardSnapshot).toHaveBeenCalledWith("user-1"); + }); + + it("does NOT evict the dashboard snapshot when nothing lands (every entry skipped)", async () => { + const res = await POST( + postReq({ + entries: [ + { + day: recentDay(), + nutrient: "vitamin_d", + unit: "mg", // wrong unit vs the catalog's canonical "ug" — skipped + amount: 22.5, + }, + ], + }), + ); + const body = (await res.json()) as BatchResponse; + expect(body.data.inserted).toBe(0); + expect(body.data.updated).toBe(0); + expect(invalidateUserDashboardSnapshot).not.toHaveBeenCalled(); + }); + it("reports updated (never duplicate) when the composite key already exists", async () => { const day = recentDay(); vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([ diff --git a/src/app/api/nutrients/batch/route.ts b/src/app/api/nutrients/batch/route.ts index 44ea0c9a3..933bc7ecc 100644 --- a/src/app/api/nutrients/batch/route.ts +++ b/src/app/api/nutrients/batch/route.ts @@ -40,6 +40,7 @@ import { import { withIdempotency } from "@/lib/idempotency"; import { checkRateLimit } from "@/lib/rate-limit"; import { requireModuleEnabled } from "@/lib/modules/gate"; +import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate"; import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog"; import { MAX_NUTRIENT_ENTRIES_PER_BATCH, @@ -324,6 +325,13 @@ async function postBatch(request: NextRequest): Promise { skipped: skipped.length, }, }); + // v1.30.3 (QA F9) — a landed row changes the dashboard's water/nutrient + // tile; without this the snapshot kept serving the pre-sync figure + // until the analytics fresh TTL lapsed (~3 min). Mirrors the manual + // water POST's own hard-evict (`nutrients/water/route.ts`) so an + // Apple-Health-synced total reflects on the very next dashboard read, + // same as a manually logged one. + invalidateUserDashboardSnapshot(user.id); } annotate({